@strav/brain 1.0.0-alpha.33 → 1.0.0-alpha.35
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/brain",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.35",
|
|
4
4
|
"description": "Strav AI module — unified Provider interface, BrainManager, threads, prompt caching, tools / agents / MCP. Anthropic + OpenAI providers; Gemini / DeepSeek follow.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
".": "./src/index.ts",
|
|
10
10
|
"./mcp": "./src/mcp/index.ts",
|
|
11
11
|
"./persistence": "./src/persistence/index.ts",
|
|
12
|
+
"./translate": "./src/translate/index.ts",
|
|
12
13
|
"./zod": "./src/zod/index.ts"
|
|
13
14
|
},
|
|
14
15
|
"files": [
|
|
@@ -25,8 +26,8 @@
|
|
|
25
26
|
"@anthropic-ai/sdk": "^0.100.0",
|
|
26
27
|
"@google/genai": "^2.7.0",
|
|
27
28
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
28
|
-
"@strav/database": "1.0.0-alpha.
|
|
29
|
-
"@strav/kernel": "1.0.0-alpha.
|
|
29
|
+
"@strav/database": "1.0.0-alpha.35",
|
|
30
|
+
"@strav/kernel": "1.0.0-alpha.35",
|
|
30
31
|
"openai": "^6.0.0"
|
|
31
32
|
},
|
|
32
33
|
"peerDependencies": {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Public API of `@strav/brain/translate`.
|
|
2
|
+
//
|
|
3
|
+
// LLM-backed translation primitive on top of `BrainManager`. Sonnet-
|
|
4
|
+
// uniform by default (tier='balanced'), with parallel fan-out across
|
|
5
|
+
// target languages, JSON-schema constrained output, prompt caching on
|
|
6
|
+
// the system prompt, and a process-local LRU for repeat strings.
|
|
7
|
+
|
|
8
|
+
export { TranslateCache, cacheKey } from './translate_cache.ts'
|
|
9
|
+
export {
|
|
10
|
+
type TranslateConfig,
|
|
11
|
+
TranslatorProvider,
|
|
12
|
+
} from './translate_provider.ts'
|
|
13
|
+
export {
|
|
14
|
+
type BatchTranslateOptions,
|
|
15
|
+
DEFAULT_SYSTEM_PROMPT,
|
|
16
|
+
type TranslateOptions,
|
|
17
|
+
Translator,
|
|
18
|
+
type TranslatorOptions,
|
|
19
|
+
} from './translator.ts'
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `TranslateCache` — tiny LRU keyed on `(model, from, to, text)`. Keeps
|
|
3
|
+
* repeated translations of the same phrase from hitting the model
|
|
4
|
+
* twice during a single fan-out (or during a batch where the same
|
|
5
|
+
* field value recurs across drafts).
|
|
6
|
+
*
|
|
7
|
+
* Intentionally in-memory + process-local. Apps that want persistent
|
|
8
|
+
* caching (e.g. across job retries / restarts) wrap their own
|
|
9
|
+
* Repository around `Translator` and call into it themselves; this
|
|
10
|
+
* cache exists to make the hot path cheap, not to be a system of
|
|
11
|
+
* record.
|
|
12
|
+
*
|
|
13
|
+
* Eviction is FIFO via Map insertion order — re-inserting on hit
|
|
14
|
+
* (`delete`+`set`) bumps the entry to the end so cold entries fall
|
|
15
|
+
* off first. `capacity: 0` disables the cache entirely.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export class TranslateCache {
|
|
19
|
+
private readonly store = new Map<string, string>()
|
|
20
|
+
|
|
21
|
+
constructor(readonly capacity: number) {}
|
|
22
|
+
|
|
23
|
+
get(key: string): string | undefined {
|
|
24
|
+
if (this.capacity === 0) return undefined
|
|
25
|
+
const hit = this.store.get(key)
|
|
26
|
+
if (hit === undefined) return undefined
|
|
27
|
+
// Bump recency.
|
|
28
|
+
this.store.delete(key)
|
|
29
|
+
this.store.set(key, hit)
|
|
30
|
+
return hit
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
set(key: string, value: string): void {
|
|
34
|
+
if (this.capacity === 0) return
|
|
35
|
+
if (this.store.has(key)) this.store.delete(key)
|
|
36
|
+
this.store.set(key, value)
|
|
37
|
+
if (this.store.size > this.capacity) {
|
|
38
|
+
// Evict the oldest entry (the first key in insertion order).
|
|
39
|
+
const oldest = this.store.keys().next().value
|
|
40
|
+
if (oldest !== undefined) this.store.delete(oldest)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
clear(): void {
|
|
45
|
+
this.store.clear()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get size(): number {
|
|
49
|
+
return this.store.size
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Stable cache key for a single (text → language) translation.
|
|
55
|
+
* Inputs are joined with a separator that can't appear in BCP-47
|
|
56
|
+
* codes; the text is hashed (FNV-1a 32-bit) to keep keys bounded.
|
|
57
|
+
*
|
|
58
|
+
* Collision risk on FNV-1a 32-bit is non-zero but acceptable for a
|
|
59
|
+
* best-effort cache: the cost of a collision is one extra LLM call
|
|
60
|
+
* the next time the loser's text hashes to the same key.
|
|
61
|
+
*/
|
|
62
|
+
export function cacheKey(input: {
|
|
63
|
+
model: string
|
|
64
|
+
from: string | undefined
|
|
65
|
+
to: string
|
|
66
|
+
text: string
|
|
67
|
+
}): string {
|
|
68
|
+
return `${input.model}|${input.from ?? 'auto'}|${input.to}|${fnv1a32(input.text)}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function fnv1a32(text: string): string {
|
|
72
|
+
let hash = 0x811c9dc5
|
|
73
|
+
for (let i = 0; i < text.length; i++) {
|
|
74
|
+
hash ^= text.charCodeAt(i)
|
|
75
|
+
hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0
|
|
76
|
+
}
|
|
77
|
+
return hash.toString(16).padStart(8, '0')
|
|
78
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `TranslatorProvider` — `ServiceProvider` that binds a default
|
|
3
|
+
* `Translator` singleton resolved against the registered
|
|
4
|
+
* `BrainManager`.
|
|
5
|
+
*
|
|
6
|
+
* Reads `config.brain.translate` (optional) for defaults — provider,
|
|
7
|
+
* tier, model, cacheSize. Apps that need multiple translators with
|
|
8
|
+
* different defaults (e.g. one for headlines, one for body) skip the
|
|
9
|
+
* provider and construct `new Translator({ brain, ... })` directly.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { type Application, ConfigRepository, ServiceProvider } from '@strav/kernel'
|
|
13
|
+
import { BrainManager } from '../brain_manager.ts'
|
|
14
|
+
import type { ModelTier } from '../types.ts'
|
|
15
|
+
import { Translator } from './translator.ts'
|
|
16
|
+
|
|
17
|
+
export interface TranslateConfig {
|
|
18
|
+
provider?: string
|
|
19
|
+
tier?: ModelTier
|
|
20
|
+
model?: string
|
|
21
|
+
systemPrompt?: string
|
|
22
|
+
cacheSize?: number
|
|
23
|
+
cache?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class TranslatorProvider extends ServiceProvider {
|
|
27
|
+
override readonly name = 'brain.translate'
|
|
28
|
+
override readonly dependencies = ['brain']
|
|
29
|
+
|
|
30
|
+
override register(app: Application): void {
|
|
31
|
+
app.singleton(Translator, (c) => {
|
|
32
|
+
const brain = c.resolve(BrainManager)
|
|
33
|
+
const cfg =
|
|
34
|
+
(c.resolve(ConfigRepository).get('brain.translate') as TranslateConfig | undefined) ?? {}
|
|
35
|
+
return new Translator({
|
|
36
|
+
brain,
|
|
37
|
+
...(cfg.provider !== undefined ? { provider: cfg.provider } : {}),
|
|
38
|
+
...(cfg.tier !== undefined ? { tier: cfg.tier } : {}),
|
|
39
|
+
...(cfg.model !== undefined ? { model: cfg.model } : {}),
|
|
40
|
+
...(cfg.systemPrompt !== undefined ? { systemPrompt: cfg.systemPrompt } : {}),
|
|
41
|
+
...(cfg.cacheSize !== undefined ? { cacheSize: cfg.cacheSize } : {}),
|
|
42
|
+
...(cfg.cache !== undefined ? { cache: cfg.cache } : {}),
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Translator` — LLM-backed translation primitive on top of
|
|
3
|
+
* `BrainManager`. Sonnet-uniform by default (`tier: 'balanced'`),
|
|
4
|
+
* which routes to `claude-sonnet-4-6` on the Anthropic driver — apps
|
|
5
|
+
* override with `options.model` or `options.provider` per call.
|
|
6
|
+
*
|
|
7
|
+
* Two entry points:
|
|
8
|
+
*
|
|
9
|
+
* - `translate(text, { to: [...] })` — fan-out one string into
|
|
10
|
+
* every target language in parallel. Returns
|
|
11
|
+
* `{ [langCode]: translated }`.
|
|
12
|
+
*
|
|
13
|
+
* - `translateBatch(fields, { to: [...] })` — translate a
|
|
14
|
+
* fixed-shape object (`{ title, body }`) into every target
|
|
15
|
+
* language. Each target language runs in parallel; within a
|
|
16
|
+
* language, all fields land in one model call so the model
|
|
17
|
+
* keeps shared context (a `title` and `body` translated
|
|
18
|
+
* together stay tonally consistent).
|
|
19
|
+
*
|
|
20
|
+
* Cross-cutting:
|
|
21
|
+
*
|
|
22
|
+
* - **Structured output.** Uses `brain.generate(input, schema)`
|
|
23
|
+
* with a JSON Schema that locks the response to the expected
|
|
24
|
+
* keys, so models never sneak in commentary or transliterations.
|
|
25
|
+
*
|
|
26
|
+
* - **Prompt caching.** The system prompt is identical across
|
|
27
|
+
* every call (per-language hints ride in the user message), so
|
|
28
|
+
* Anthropic prompt caching kicks in once the cache window warms.
|
|
29
|
+
* Set `cache: false` on the constructor to opt out.
|
|
30
|
+
*
|
|
31
|
+
* - **In-memory cache.** Identical `(model, from, to, text)`
|
|
32
|
+
* tuples are served from a process-local LRU (default 1000
|
|
33
|
+
* entries) — see `TranslateCache`. Pass `cacheSize: 0` to
|
|
34
|
+
* disable.
|
|
35
|
+
*
|
|
36
|
+
* - **Source language auto-detect.** Omit `from` and the user
|
|
37
|
+
* message tells the model to detect the source. Apps that know
|
|
38
|
+
* the source pass it explicitly for marginal quality + token
|
|
39
|
+
* savings.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import type { BrainManager } from '../brain_manager.ts'
|
|
43
|
+
import type { OutputSchema } from '../output_schema.ts'
|
|
44
|
+
import type { ChatOptions, ModelTier } from '../types.ts'
|
|
45
|
+
import { cacheKey, TranslateCache } from './translate_cache.ts'
|
|
46
|
+
|
|
47
|
+
export interface TranslatorOptions {
|
|
48
|
+
brain: BrainManager
|
|
49
|
+
/** Brain provider name. Defaults to the configured `brain.default`. */
|
|
50
|
+
provider?: string
|
|
51
|
+
/** Brain tier sugar — overridden by `model`. Default `'balanced'` (Sonnet on Anthropic per ADR-0004-style routing). */
|
|
52
|
+
tier?: ModelTier
|
|
53
|
+
/** Explicit model id. Wins over `tier`. */
|
|
54
|
+
model?: string
|
|
55
|
+
/** Override the system prompt. Apps localising the prompt itself reach for this. */
|
|
56
|
+
systemPrompt?: string
|
|
57
|
+
/** LRU capacity for the translation cache. `0` disables. Default `1000`. */
|
|
58
|
+
cacheSize?: number
|
|
59
|
+
/** Enable Anthropic prompt caching on the system prompt. Default `true`. Non-Anthropic providers ignore. */
|
|
60
|
+
cache?: boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface TranslateOptions {
|
|
64
|
+
/** Target BCP-47 language codes (`'th'`, `'zh-Hant'`, `'ja'`). */
|
|
65
|
+
to: readonly string[]
|
|
66
|
+
/** Source BCP-47 code. Omit to ask the model to detect. */
|
|
67
|
+
from?: string
|
|
68
|
+
/** Per-call model override (wins over the constructor's tier/model). */
|
|
69
|
+
model?: string
|
|
70
|
+
/** Per-call provider override. */
|
|
71
|
+
provider?: string
|
|
72
|
+
/** Cancellation signal — forwarded to every parallel `brain.generate` call. */
|
|
73
|
+
signal?: AbortSignal
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type BatchTranslateOptions = TranslateOptions
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Default system prompt — kept stable across every call so prompt
|
|
80
|
+
* caching can warm. Per-call specifics (source/target language,
|
|
81
|
+
* text, field shape) ride in the user message.
|
|
82
|
+
*/
|
|
83
|
+
export const DEFAULT_SYSTEM_PROMPT = `You are a translation engine.
|
|
84
|
+
|
|
85
|
+
The user supplies (a) a source-language code (or "auto"), (b) a target BCP-47 language code, and (c) the source text or a JSON object of named source fields. Translate the source into the target language and output ONLY the translation in the required JSON shape.
|
|
86
|
+
|
|
87
|
+
Rules:
|
|
88
|
+
- Output ONLY the translated text in the requested JSON shape. Do not add explanations, notes, alternatives, or transliterations.
|
|
89
|
+
- Preserve Markdown, HTML tags, links, mentions, hashtags, code spans, and emoji exactly as in the source.
|
|
90
|
+
- Keep numbers, dates, currency symbols, and proper nouns recognisable in the target locale; do not invent translations for brand names.
|
|
91
|
+
- If the source is already in the target language, output it unchanged.
|
|
92
|
+
- For batch translations, every requested field must appear in the output — never drop a field.`
|
|
93
|
+
|
|
94
|
+
export class Translator {
|
|
95
|
+
private readonly brain: BrainManager
|
|
96
|
+
private readonly provider: string | undefined
|
|
97
|
+
private readonly tier: ModelTier
|
|
98
|
+
private readonly explicitModel: string | undefined
|
|
99
|
+
private readonly systemPrompt: string
|
|
100
|
+
private readonly cache: TranslateCache
|
|
101
|
+
private readonly promptCache: boolean
|
|
102
|
+
|
|
103
|
+
constructor(options: TranslatorOptions) {
|
|
104
|
+
this.brain = options.brain
|
|
105
|
+
this.provider = options.provider
|
|
106
|
+
this.tier = options.tier ?? 'balanced'
|
|
107
|
+
this.explicitModel = options.model
|
|
108
|
+
this.systemPrompt = options.systemPrompt ?? DEFAULT_SYSTEM_PROMPT
|
|
109
|
+
this.cache = new TranslateCache(options.cacheSize ?? 1000)
|
|
110
|
+
this.promptCache = options.cache ?? true
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Translate one string into every target language in parallel.
|
|
115
|
+
* Returns a `{ [lang]: translated }` map containing one entry per
|
|
116
|
+
* code in `options.to`. Calls fan out concurrently; a single
|
|
117
|
+
* thrown call rejects the whole `Promise.all`.
|
|
118
|
+
*/
|
|
119
|
+
async translate(
|
|
120
|
+
text: string,
|
|
121
|
+
options: TranslateOptions,
|
|
122
|
+
): Promise<Record<string, string>> {
|
|
123
|
+
if (options.to.length === 0) return {}
|
|
124
|
+
|
|
125
|
+
const results = await Promise.all(
|
|
126
|
+
options.to.map(async (lang) => {
|
|
127
|
+
const translated = await this.translateOne(text, lang, options)
|
|
128
|
+
return [lang, translated] as const
|
|
129
|
+
}),
|
|
130
|
+
)
|
|
131
|
+
return Object.fromEntries(results)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Translate a fixed-shape object of fields into every target
|
|
136
|
+
* language. Each target language runs in parallel; within a
|
|
137
|
+
* language, all fields are translated in one model call so context
|
|
138
|
+
* is shared.
|
|
139
|
+
*
|
|
140
|
+
* Returns `{ [lang]: { ...fields } }`. The shape of every per-
|
|
141
|
+
* language object matches the input keys exactly — missing keys
|
|
142
|
+
* are treated as a hard error (the model is instructed to never
|
|
143
|
+
* drop a field) and surface as a `BrainError` from `generate`'s
|
|
144
|
+
* schema parser.
|
|
145
|
+
*/
|
|
146
|
+
async translateBatch<T extends Record<string, string>>(
|
|
147
|
+
fields: T,
|
|
148
|
+
options: BatchTranslateOptions,
|
|
149
|
+
): Promise<Record<string, T>> {
|
|
150
|
+
if (options.to.length === 0) return {}
|
|
151
|
+
const fieldNames = Object.keys(fields) as Array<keyof T & string>
|
|
152
|
+
if (fieldNames.length === 0) return Object.fromEntries(options.to.map((l) => [l, {} as T]))
|
|
153
|
+
|
|
154
|
+
const results = await Promise.all(
|
|
155
|
+
options.to.map(async (lang) => {
|
|
156
|
+
const translated = await this.translateBatchOne(fields, fieldNames, lang, options)
|
|
157
|
+
return [lang, translated] as const
|
|
158
|
+
}),
|
|
159
|
+
)
|
|
160
|
+
return Object.fromEntries(results)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Drop the in-memory LRU. Useful in tests to keep cases isolated. */
|
|
164
|
+
clearCache(): void {
|
|
165
|
+
this.cache.clear()
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── internals ──────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
private resolvedModel(per: TranslateOptions): string {
|
|
171
|
+
return per.model ?? this.explicitModel ?? this.tier
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private buildChatOptions(per: TranslateOptions): ChatOptions {
|
|
175
|
+
const opts: ChatOptions = {
|
|
176
|
+
system: this.promptCache
|
|
177
|
+
? { text: this.systemPrompt, cache: true }
|
|
178
|
+
: this.systemPrompt,
|
|
179
|
+
}
|
|
180
|
+
if (per.model) opts.model = per.model
|
|
181
|
+
else if (this.explicitModel) opts.model = this.explicitModel
|
|
182
|
+
else opts.tier = this.tier
|
|
183
|
+
if (per.provider ?? this.provider) opts.provider = (per.provider ?? this.provider)!
|
|
184
|
+
if (per.signal) opts.signal = per.signal
|
|
185
|
+
return opts
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private async translateOne(
|
|
189
|
+
text: string,
|
|
190
|
+
lang: string,
|
|
191
|
+
per: TranslateOptions,
|
|
192
|
+
): Promise<string> {
|
|
193
|
+
const model = this.resolvedModel(per)
|
|
194
|
+
const key = cacheKey({ model, from: per.from, to: lang, text })
|
|
195
|
+
const hit = this.cache.get(key)
|
|
196
|
+
if (hit !== undefined) return hit
|
|
197
|
+
|
|
198
|
+
const schema: OutputSchema<{ translation: string }> = {
|
|
199
|
+
name: 'translation',
|
|
200
|
+
description: `Translation of the source text into ${lang}.`,
|
|
201
|
+
jsonSchema: {
|
|
202
|
+
type: 'object',
|
|
203
|
+
properties: { translation: { type: 'string' } },
|
|
204
|
+
required: ['translation'],
|
|
205
|
+
additionalProperties: false,
|
|
206
|
+
},
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const userMessage = `SOURCE_LANGUAGE: ${per.from ?? 'auto'}\nTARGET_LANGUAGE: ${lang}\nTEXT:\n${text}`
|
|
210
|
+
|
|
211
|
+
const result = await this.brain.generate(userMessage, schema, this.buildChatOptions(per))
|
|
212
|
+
const translated = result.value.translation
|
|
213
|
+
this.cache.set(key, translated)
|
|
214
|
+
return translated
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private async translateBatchOne<T extends Record<string, string>>(
|
|
218
|
+
fields: T,
|
|
219
|
+
fieldNames: readonly (keyof T & string)[],
|
|
220
|
+
lang: string,
|
|
221
|
+
per: BatchTranslateOptions,
|
|
222
|
+
): Promise<T> {
|
|
223
|
+
const model = this.resolvedModel(per)
|
|
224
|
+
|
|
225
|
+
// Per-field cache: check every field; only call the model when at
|
|
226
|
+
// least one field is missing. The single model call still covers
|
|
227
|
+
// all fields (we don't sub-call per missing field — the context
|
|
228
|
+
// gain from a single call outweighs the extra translation work).
|
|
229
|
+
const fromCache: Partial<Record<string, string>> = {}
|
|
230
|
+
let allHit = true
|
|
231
|
+
for (const name of fieldNames) {
|
|
232
|
+
const hit = this.cache.get(
|
|
233
|
+
cacheKey({ model, from: per.from, to: lang, text: fields[name]! }),
|
|
234
|
+
)
|
|
235
|
+
if (hit === undefined) {
|
|
236
|
+
allHit = false
|
|
237
|
+
} else {
|
|
238
|
+
fromCache[name] = hit
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (allHit) return fromCache as T
|
|
242
|
+
|
|
243
|
+
const properties: Record<string, unknown> = {}
|
|
244
|
+
for (const name of fieldNames) properties[name] = { type: 'string' }
|
|
245
|
+
const schema: OutputSchema<T> = {
|
|
246
|
+
name: 'batch_translation',
|
|
247
|
+
description: `Translation of every named field into ${lang}.`,
|
|
248
|
+
jsonSchema: {
|
|
249
|
+
type: 'object',
|
|
250
|
+
properties,
|
|
251
|
+
required: [...fieldNames],
|
|
252
|
+
additionalProperties: false,
|
|
253
|
+
},
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const fieldsBlock = fieldNames
|
|
257
|
+
.map((n) => `- ${n}: ${JSON.stringify(fields[n]!)}`)
|
|
258
|
+
.join('\n')
|
|
259
|
+
const userMessage = `SOURCE_LANGUAGE: ${per.from ?? 'auto'}\nTARGET_LANGUAGE: ${lang}\nFIELDS:\n${fieldsBlock}\n\nOutput a JSON object with these exact keys: ${fieldNames.join(', ')}.`
|
|
260
|
+
|
|
261
|
+
const result = await this.brain.generate(userMessage, schema, this.buildChatOptions(per))
|
|
262
|
+
const translated = result.value
|
|
263
|
+
for (const name of fieldNames) {
|
|
264
|
+
this.cache.set(
|
|
265
|
+
cacheKey({ model, from: per.from, to: lang, text: fields[name]! }),
|
|
266
|
+
translated[name]!,
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
return translated
|
|
270
|
+
}
|
|
271
|
+
}
|