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 +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +5 -0
- package/dist/handler.d.ts +9 -0
- package/dist/handler.js +68 -29
- package/dist/providers/prompts.js +14 -10
- package/package.json +1 -1
- package/src/cli.ts +1 -1
- package/src/config.ts +9 -0
- package/src/handler.ts +162 -117
- package/src/providers/prompts.ts +18 -11
- package/tests/ait.test.ts +1 -0
- package/tests/backend.test.ts +31 -7
- package/tests/config.test.ts +26 -0
- package/tests/handler.test.ts +164 -1
- package/tests/hooks.test.ts +1 -0
- package/tests/providers.test.ts +8 -4
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.
|
|
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
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
142
|
+
return [key, item.text];
|
|
109
143
|
}
|
|
110
|
-
}
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
+
return [responseKey, forms];
|
|
166
195
|
}
|
|
167
196
|
catch {
|
|
168
|
-
|
|
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
|
|
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
|
|
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}"
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
.replace('{
|
|
52
|
-
|
|
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
|
-
|
|
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
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.
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
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
|
-
|
|
143
|
-
}
|
|
144
|
-
|
|
182
|
+
return [key, item.text];
|
|
183
|
+
} catch {
|
|
184
|
+
return [key, item.text];
|
|
145
185
|
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
}
|
|
186
|
+
})
|
|
187
|
+
);
|
|
150
188
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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:
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
}
|
package/src/providers/prompts.ts
CHANGED
|
@@ -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
|
|
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
|
|
19
|
-
'
|
|
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}"
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
.replace('{
|
|
81
|
-
|
|
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
|
-
|
|
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
package/tests/backend.test.ts
CHANGED
|
@@ -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
|
-
|
|
39
|
-
expect(messages[1].content).
|
|
42
|
+
// User message must be ONLY the source text.
|
|
43
|
+
expect(messages[1].content).toBe('Our Events');
|
|
40
44
|
});
|
|
41
45
|
|
|
42
|
-
it('
|
|
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[
|
|
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
|
-
|
|
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('
|
|
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[
|
|
159
|
+
expect(messages[0].content).not.toContain('String context');
|
|
160
|
+
expect(messages[1].content).not.toContain('String context');
|
|
137
161
|
});
|
|
138
162
|
});
|
|
139
163
|
|
package/tests/config.test.ts
CHANGED
|
@@ -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);
|
package/tests/handler.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/tests/hooks.test.ts
CHANGED
package/tests/providers.test.ts
CHANGED
|
@@ -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
|
-
|
|
53
|
-
expect(messages[1].content).
|
|
55
|
+
// User message must be ONLY the source text.
|
|
56
|
+
expect(messages[1].content).toBe('Hello');
|
|
54
57
|
});
|
|
55
58
|
|
|
56
|
-
it('
|
|
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[
|
|
67
|
+
expect(messages[0].content).not.toContain('String context');
|
|
68
|
+
expect(messages[1].content).toBe('Hello');
|
|
65
69
|
});
|
|
66
70
|
});
|
|
67
71
|
|