melaka 0.0.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/CONTRIBUTING.md +347 -0
- package/LICENSE +21 -0
- package/README.md +57 -0
- package/docs/AI_PROVIDERS.md +343 -0
- package/docs/ARCHITECTURE.md +512 -0
- package/docs/CLI.md +438 -0
- package/docs/CONFIGURATION.md +453 -0
- package/docs/INTEGRATION.md +477 -0
- package/docs/ROADMAP.md +248 -0
- package/package.json +46 -0
- package/packages/ai/README.md +43 -0
- package/packages/ai/package.json +42 -0
- package/packages/ai/src/facade.ts +120 -0
- package/packages/ai/src/index.ts +34 -0
- package/packages/ai/src/prompt.ts +117 -0
- package/packages/ai/src/providers/gemini.ts +185 -0
- package/packages/ai/src/providers/index.ts +9 -0
- package/packages/ai/src/types.ts +134 -0
- package/packages/ai/tsconfig.json +19 -0
- package/packages/cli/README.md +70 -0
- package/packages/cli/package.json +44 -0
- package/packages/cli/src/cli.ts +30 -0
- package/packages/cli/src/commands/deploy.ts +115 -0
- package/packages/cli/src/commands/index.ts +9 -0
- package/packages/cli/src/commands/init.ts +107 -0
- package/packages/cli/src/commands/status.ts +73 -0
- package/packages/cli/src/commands/translate.ts +92 -0
- package/packages/cli/src/commands/validate.ts +69 -0
- package/packages/cli/tsconfig.json +19 -0
- package/packages/core/README.md +46 -0
- package/packages/core/package.json +50 -0
- package/packages/core/src/config.ts +241 -0
- package/packages/core/src/index.ts +111 -0
- package/packages/core/src/schema-generator.ts +263 -0
- package/packages/core/src/schemas.ts +126 -0
- package/packages/core/src/types.ts +481 -0
- package/packages/core/src/utils.ts +343 -0
- package/packages/core/tsconfig.json +19 -0
- package/packages/firestore/README.md +60 -0
- package/packages/firestore/package.json +48 -0
- package/packages/firestore/src/generator.ts +270 -0
- package/packages/firestore/src/i18n.ts +262 -0
- package/packages/firestore/src/index.ts +54 -0
- package/packages/firestore/src/processor.ts +245 -0
- package/packages/firestore/src/queue.ts +202 -0
- package/packages/firestore/src/task-handler.ts +164 -0
- package/packages/firestore/tsconfig.json +19 -0
- package/pnpm-workspace.yaml +2 -0
- package/turbo.json +31 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Melaka Firestore - Translation Processor
|
|
3
|
+
*
|
|
4
|
+
* Core translation logic that processes documents and writes translations.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { DocumentReference, DocumentData, Timestamp } from 'firebase-admin/firestore';
|
|
8
|
+
import {
|
|
9
|
+
separateContent,
|
|
10
|
+
hashContent,
|
|
11
|
+
mergeGlossaries,
|
|
12
|
+
createTranslationSchema,
|
|
13
|
+
type MelakaConfig,
|
|
14
|
+
type CollectionConfig,
|
|
15
|
+
type MelakaMetadata,
|
|
16
|
+
getEffectiveAIConfig,
|
|
17
|
+
} from '@melaka/core';
|
|
18
|
+
import { createTranslationFacade, type TranslationResponse } from '@melaka/ai';
|
|
19
|
+
import {
|
|
20
|
+
readMelakaMetadata,
|
|
21
|
+
writeTranslation,
|
|
22
|
+
markTranslationFailed,
|
|
23
|
+
} from './i18n';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Options for processing a translation.
|
|
27
|
+
*/
|
|
28
|
+
export interface ProcessOptions {
|
|
29
|
+
/**
|
|
30
|
+
* Force re-translation even if content unchanged.
|
|
31
|
+
*/
|
|
32
|
+
forceUpdate?: boolean;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Timestamp to use for translated_at field.
|
|
36
|
+
*/
|
|
37
|
+
timestamp?: Timestamp;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Result of processing a translation.
|
|
42
|
+
*/
|
|
43
|
+
export interface ProcessResult {
|
|
44
|
+
/**
|
|
45
|
+
* Whether processing succeeded.
|
|
46
|
+
*/
|
|
47
|
+
success: boolean;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether translation was skipped (content unchanged).
|
|
51
|
+
*/
|
|
52
|
+
skipped: boolean;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Error message if failed.
|
|
56
|
+
*/
|
|
57
|
+
error?: string;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Duration in milliseconds.
|
|
61
|
+
*/
|
|
62
|
+
durationMs?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Process a single document translation.
|
|
67
|
+
*
|
|
68
|
+
* @param docRef - Document reference to translate
|
|
69
|
+
* @param docData - Document data
|
|
70
|
+
* @param targetLanguage - Target locale code
|
|
71
|
+
* @param config - Root Melaka configuration
|
|
72
|
+
* @param collectionConfig - Collection-specific configuration
|
|
73
|
+
* @param options - Processing options
|
|
74
|
+
* @returns Processing result
|
|
75
|
+
*/
|
|
76
|
+
export async function processTranslation(
|
|
77
|
+
docRef: DocumentReference,
|
|
78
|
+
docData: DocumentData,
|
|
79
|
+
targetLanguage: string,
|
|
80
|
+
config: MelakaConfig,
|
|
81
|
+
collectionConfig: CollectionConfig,
|
|
82
|
+
options: ProcessOptions = {}
|
|
83
|
+
): Promise<ProcessResult> {
|
|
84
|
+
const startTime = Date.now();
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
// 1. Separate content into translatable and non-translatable
|
|
88
|
+
const { translatable, nonTranslatable, detectedTypes } = separateContent(
|
|
89
|
+
docData as Record<string, unknown>,
|
|
90
|
+
collectionConfig.fields
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// Check if there's anything to translate
|
|
94
|
+
if (Object.keys(translatable).length === 0) {
|
|
95
|
+
return {
|
|
96
|
+
success: true,
|
|
97
|
+
skipped: true,
|
|
98
|
+
durationMs: Date.now() - startTime,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 2. Calculate content hash for change detection
|
|
103
|
+
const sourceHash = hashContent(translatable);
|
|
104
|
+
|
|
105
|
+
// 3. Check if translation is already current (unless forceUpdate)
|
|
106
|
+
if (!options.forceUpdate) {
|
|
107
|
+
const existingMetadata = await readMelakaMetadata(docRef, targetLanguage);
|
|
108
|
+
|
|
109
|
+
if (
|
|
110
|
+
existingMetadata &&
|
|
111
|
+
existingMetadata.status === 'completed' &&
|
|
112
|
+
existingMetadata.source_hash === sourceHash
|
|
113
|
+
) {
|
|
114
|
+
return {
|
|
115
|
+
success: true,
|
|
116
|
+
skipped: true,
|
|
117
|
+
durationMs: Date.now() - startTime,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 4. Create translation schema
|
|
123
|
+
const schema = createTranslationSchema({ translatable, detectedTypes });
|
|
124
|
+
|
|
125
|
+
// 5. Get effective AI config and create facade
|
|
126
|
+
const aiConfig = getEffectiveAIConfig(config, collectionConfig);
|
|
127
|
+
const facade = createTranslationFacade(aiConfig);
|
|
128
|
+
|
|
129
|
+
// 6. Merge glossaries
|
|
130
|
+
const glossary = mergeGlossaries(config.glossary, collectionConfig.glossary);
|
|
131
|
+
|
|
132
|
+
// 7. Perform translation
|
|
133
|
+
const response: TranslationResponse<Record<string, unknown>> = await facade.translate(
|
|
134
|
+
translatable,
|
|
135
|
+
schema,
|
|
136
|
+
{
|
|
137
|
+
targetLanguage,
|
|
138
|
+
prompt: collectionConfig.prompt,
|
|
139
|
+
glossary,
|
|
140
|
+
temperature: aiConfig.temperature,
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// 8. Handle translation result
|
|
145
|
+
const timestamp = options.timestamp || (await getTimestamp(docRef));
|
|
146
|
+
|
|
147
|
+
if (!response.success || !response.output) {
|
|
148
|
+
await markTranslationFailed(
|
|
149
|
+
docRef,
|
|
150
|
+
targetLanguage,
|
|
151
|
+
response.error || 'Unknown translation error',
|
|
152
|
+
timestamp
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
success: false,
|
|
157
|
+
skipped: false,
|
|
158
|
+
error: response.error,
|
|
159
|
+
durationMs: Date.now() - startTime,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 9. Create metadata
|
|
164
|
+
const metadata: MelakaMetadata = {
|
|
165
|
+
source_hash: sourceHash,
|
|
166
|
+
translated_at: timestamp,
|
|
167
|
+
model: facade.getModel(),
|
|
168
|
+
status: 'completed',
|
|
169
|
+
reviewed: false,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// 10. Write translation
|
|
173
|
+
await writeTranslation(
|
|
174
|
+
docRef,
|
|
175
|
+
targetLanguage,
|
|
176
|
+
response.output,
|
|
177
|
+
nonTranslatable,
|
|
178
|
+
metadata
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
success: true,
|
|
183
|
+
skipped: false,
|
|
184
|
+
durationMs: Date.now() - startTime,
|
|
185
|
+
};
|
|
186
|
+
} catch (error) {
|
|
187
|
+
const timestamp = options.timestamp || (await getTimestamp(docRef));
|
|
188
|
+
|
|
189
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
await markTranslationFailed(docRef, targetLanguage, errorMessage, timestamp);
|
|
193
|
+
} catch {
|
|
194
|
+
// Ignore errors while marking failed
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
success: false,
|
|
199
|
+
skipped: false,
|
|
200
|
+
error: errorMessage,
|
|
201
|
+
durationMs: Date.now() - startTime,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Process translations for all configured languages.
|
|
208
|
+
*
|
|
209
|
+
* @param docRef - Document reference to translate
|
|
210
|
+
* @param docData - Document data
|
|
211
|
+
* @param config - Root Melaka configuration
|
|
212
|
+
* @param collectionConfig - Collection-specific configuration
|
|
213
|
+
* @param options - Processing options
|
|
214
|
+
* @returns Results for each language
|
|
215
|
+
*/
|
|
216
|
+
export async function processAllLanguages(
|
|
217
|
+
docRef: DocumentReference,
|
|
218
|
+
docData: DocumentData,
|
|
219
|
+
config: MelakaConfig,
|
|
220
|
+
collectionConfig: CollectionConfig,
|
|
221
|
+
options: ProcessOptions = {}
|
|
222
|
+
): Promise<Record<string, ProcessResult>> {
|
|
223
|
+
const results: Record<string, ProcessResult> = {};
|
|
224
|
+
|
|
225
|
+
for (const language of config.languages) {
|
|
226
|
+
results[language] = await processTranslation(
|
|
227
|
+
docRef,
|
|
228
|
+
docData,
|
|
229
|
+
language,
|
|
230
|
+
config,
|
|
231
|
+
collectionConfig,
|
|
232
|
+
options
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return results;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Get a Firestore Timestamp.
|
|
241
|
+
*/
|
|
242
|
+
async function getTimestamp(docRef: DocumentReference): Promise<Timestamp> {
|
|
243
|
+
const { Timestamp } = await import('firebase-admin/firestore');
|
|
244
|
+
return Timestamp.now();
|
|
245
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Melaka Firestore - Task Queue
|
|
3
|
+
*
|
|
4
|
+
* Functions for enqueueing translation tasks to Cloud Tasks.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Firestore } from 'firebase-admin/firestore';
|
|
8
|
+
import {
|
|
9
|
+
generateBatchId,
|
|
10
|
+
type MelakaConfig,
|
|
11
|
+
type CollectionConfig,
|
|
12
|
+
getEffectiveMaxConcurrency,
|
|
13
|
+
} from '@melaka/core';
|
|
14
|
+
import type { TranslationTaskPayload } from '@melaka/core';
|
|
15
|
+
import { createTaskPayload } from './task-handler';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Task queue interface for Cloud Functions.
|
|
19
|
+
*/
|
|
20
|
+
export interface TaskQueue {
|
|
21
|
+
enqueue(
|
|
22
|
+
data: TranslationTaskPayload,
|
|
23
|
+
options?: { scheduleDelaySeconds?: number }
|
|
24
|
+
): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Options for enqueueing translations.
|
|
29
|
+
*/
|
|
30
|
+
export interface EnqueueOptions {
|
|
31
|
+
/**
|
|
32
|
+
* Task queue instance.
|
|
33
|
+
*/
|
|
34
|
+
queue: TaskQueue;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Base delay between task scheduling (staggering).
|
|
38
|
+
*/
|
|
39
|
+
staggerDelayMs?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Result of enqueueing translations.
|
|
44
|
+
*/
|
|
45
|
+
export interface EnqueueResult {
|
|
46
|
+
/**
|
|
47
|
+
* Batch ID for tracking.
|
|
48
|
+
*/
|
|
49
|
+
batchId: string;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Number of tasks enqueued.
|
|
53
|
+
*/
|
|
54
|
+
tasksEnqueued: number;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Number of tasks that failed to enqueue.
|
|
58
|
+
*/
|
|
59
|
+
failed: number;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Error messages for failed tasks.
|
|
63
|
+
*/
|
|
64
|
+
errors: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Enqueue translation tasks for a single document.
|
|
69
|
+
*
|
|
70
|
+
* Creates one task per target language.
|
|
71
|
+
*
|
|
72
|
+
* @param collectionPath - Collection path
|
|
73
|
+
* @param documentId - Document ID
|
|
74
|
+
* @param config - Melaka configuration
|
|
75
|
+
* @param collectionConfig - Collection configuration
|
|
76
|
+
* @param options - Queue options
|
|
77
|
+
* @returns Enqueue result
|
|
78
|
+
*/
|
|
79
|
+
export async function enqueueDocumentTranslation(
|
|
80
|
+
collectionPath: string,
|
|
81
|
+
documentId: string,
|
|
82
|
+
config: MelakaConfig,
|
|
83
|
+
collectionConfig: CollectionConfig,
|
|
84
|
+
options: EnqueueOptions
|
|
85
|
+
): Promise<EnqueueResult> {
|
|
86
|
+
const batchId = generateBatchId();
|
|
87
|
+
const staggerDelay = options.staggerDelayMs || 100;
|
|
88
|
+
let tasksEnqueued = 0;
|
|
89
|
+
let failed = 0;
|
|
90
|
+
const errors: string[] = [];
|
|
91
|
+
|
|
92
|
+
for (let i = 0; i < config.languages.length; i++) {
|
|
93
|
+
const language = config.languages[i];
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const payload = createTaskPayload(
|
|
97
|
+
collectionPath,
|
|
98
|
+
documentId,
|
|
99
|
+
language,
|
|
100
|
+
collectionConfig,
|
|
101
|
+
batchId
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
await options.queue.enqueue(payload, {
|
|
105
|
+
scheduleDelaySeconds: Math.floor((i * staggerDelay) / 1000),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
tasksEnqueued++;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
failed++;
|
|
111
|
+
errors.push(
|
|
112
|
+
`Failed to enqueue ${documentId}/${language}: ${error instanceof Error ? error.message : String(error)}`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
batchId,
|
|
119
|
+
tasksEnqueued,
|
|
120
|
+
failed,
|
|
121
|
+
errors,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Enqueue translation tasks for all documents in a collection.
|
|
127
|
+
*
|
|
128
|
+
* @param firestore - Firestore instance
|
|
129
|
+
* @param config - Melaka configuration
|
|
130
|
+
* @param collectionConfig - Collection configuration
|
|
131
|
+
* @param options - Queue options
|
|
132
|
+
* @returns Enqueue result
|
|
133
|
+
*/
|
|
134
|
+
export async function enqueueCollectionTranslation(
|
|
135
|
+
firestore: Firestore,
|
|
136
|
+
config: MelakaConfig,
|
|
137
|
+
collectionConfig: CollectionConfig,
|
|
138
|
+
options: EnqueueOptions
|
|
139
|
+
): Promise<EnqueueResult> {
|
|
140
|
+
const batchId = generateBatchId();
|
|
141
|
+
const maxConcurrency = getEffectiveMaxConcurrency(config, collectionConfig);
|
|
142
|
+
const staggerDelay = options.staggerDelayMs || 100;
|
|
143
|
+
|
|
144
|
+
let tasksEnqueued = 0;
|
|
145
|
+
let failed = 0;
|
|
146
|
+
const errors: string[] = [];
|
|
147
|
+
|
|
148
|
+
// Query collection
|
|
149
|
+
let query: FirebaseFirestore.Query;
|
|
150
|
+
if (collectionConfig.isCollectionGroup) {
|
|
151
|
+
query = firestore.collectionGroup(collectionConfig.path);
|
|
152
|
+
} else {
|
|
153
|
+
query = firestore.collection(collectionConfig.path);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const snapshots = await query.select().get();
|
|
157
|
+
const totalDocs = snapshots.docs.length;
|
|
158
|
+
const totalTasks = totalDocs * config.languages.length;
|
|
159
|
+
|
|
160
|
+
let taskIndex = 0;
|
|
161
|
+
|
|
162
|
+
for (const doc of snapshots.docs) {
|
|
163
|
+
const collectionPath = collectionConfig.isCollectionGroup
|
|
164
|
+
? doc.ref.parent.path
|
|
165
|
+
: collectionConfig.path;
|
|
166
|
+
|
|
167
|
+
for (const language of config.languages) {
|
|
168
|
+
try {
|
|
169
|
+
const payload = createTaskPayload(
|
|
170
|
+
collectionPath,
|
|
171
|
+
doc.id,
|
|
172
|
+
language,
|
|
173
|
+
collectionConfig,
|
|
174
|
+
batchId
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// Calculate delay for staggering
|
|
178
|
+
const delaySeconds = Math.floor((taskIndex * staggerDelay) / 1000);
|
|
179
|
+
|
|
180
|
+
await options.queue.enqueue(payload, {
|
|
181
|
+
scheduleDelaySeconds: delaySeconds,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
tasksEnqueued++;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
failed++;
|
|
187
|
+
errors.push(
|
|
188
|
+
`Failed to enqueue ${doc.id}/${language}: ${error instanceof Error ? error.message : String(error)}`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
taskIndex++;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
batchId,
|
|
198
|
+
tasksEnqueued,
|
|
199
|
+
failed,
|
|
200
|
+
errors,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Melaka Firestore - Cloud Task Handler
|
|
3
|
+
*
|
|
4
|
+
* Task queue handler for async translation processing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { DocumentReference } from 'firebase-admin/firestore';
|
|
8
|
+
import {
|
|
9
|
+
TranslationTaskPayloadSchema,
|
|
10
|
+
type TranslationTaskPayload,
|
|
11
|
+
type MelakaConfig,
|
|
12
|
+
findCollectionConfig,
|
|
13
|
+
} from '@melaka/core';
|
|
14
|
+
import { processTranslation, type ProcessResult } from './processor';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Task handler context with Firebase dependencies.
|
|
18
|
+
*/
|
|
19
|
+
export interface TaskHandlerContext {
|
|
20
|
+
/**
|
|
21
|
+
* Firestore instance.
|
|
22
|
+
*/
|
|
23
|
+
firestore: FirebaseFirestore.Firestore;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Melaka configuration.
|
|
27
|
+
*/
|
|
28
|
+
config: MelakaConfig;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* API key for AI provider (resolved from secret).
|
|
32
|
+
*/
|
|
33
|
+
apiKey: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Handle a translation task from Cloud Tasks.
|
|
38
|
+
*
|
|
39
|
+
* This function is called by the `onTaskDispatched` Cloud Function.
|
|
40
|
+
*
|
|
41
|
+
* @param payload - Task payload
|
|
42
|
+
* @param context - Handler context with Firestore and config
|
|
43
|
+
* @returns Processing result
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```typescript
|
|
47
|
+
* // In generated Cloud Function:
|
|
48
|
+
* export const melakaTranslateTask = onTaskDispatched(
|
|
49
|
+
* { secrets: [geminiApiKey] },
|
|
50
|
+
* async (request) => {
|
|
51
|
+
* const result = await handleTranslationTask(request.data, {
|
|
52
|
+
* firestore: getFirestore(),
|
|
53
|
+
* config: await loadConfig(),
|
|
54
|
+
* apiKey: geminiApiKey.value(),
|
|
55
|
+
* });
|
|
56
|
+
*
|
|
57
|
+
* if (!result.success) {
|
|
58
|
+
* throw new Error(result.error);
|
|
59
|
+
* }
|
|
60
|
+
* }
|
|
61
|
+
* );
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export async function handleTranslationTask(
|
|
65
|
+
payload: unknown,
|
|
66
|
+
context: TaskHandlerContext
|
|
67
|
+
): Promise<ProcessResult> {
|
|
68
|
+
// 1. Validate payload
|
|
69
|
+
const parseResult = TranslationTaskPayloadSchema.safeParse(payload);
|
|
70
|
+
if (!parseResult.success) {
|
|
71
|
+
return {
|
|
72
|
+
success: false,
|
|
73
|
+
skipped: false,
|
|
74
|
+
error: `Invalid task payload: ${parseResult.error.message}`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const taskPayload = parseResult.data as TranslationTaskPayload;
|
|
79
|
+
|
|
80
|
+
// 2. Get document reference
|
|
81
|
+
const docRef = context.firestore
|
|
82
|
+
.collection(taskPayload.collectionPath)
|
|
83
|
+
.doc(taskPayload.documentId);
|
|
84
|
+
|
|
85
|
+
// 3. Read document
|
|
86
|
+
const docSnapshot = await docRef.get();
|
|
87
|
+
if (!docSnapshot.exists) {
|
|
88
|
+
return {
|
|
89
|
+
success: true,
|
|
90
|
+
skipped: true,
|
|
91
|
+
error: 'Document no longer exists',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const docData = docSnapshot.data();
|
|
96
|
+
if (!docData) {
|
|
97
|
+
return {
|
|
98
|
+
success: true,
|
|
99
|
+
skipped: true,
|
|
100
|
+
error: 'Document has no data',
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 4. Find collection config
|
|
105
|
+
const collectionConfig = findCollectionConfig(
|
|
106
|
+
context.config,
|
|
107
|
+
taskPayload.collectionPath
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
if (!collectionConfig) {
|
|
111
|
+
return {
|
|
112
|
+
success: false,
|
|
113
|
+
skipped: false,
|
|
114
|
+
error: `Collection not configured: ${taskPayload.collectionPath}`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 5. Inject API key into config
|
|
119
|
+
const configWithKey: MelakaConfig = {
|
|
120
|
+
...context.config,
|
|
121
|
+
ai: {
|
|
122
|
+
...context.config.ai,
|
|
123
|
+
apiKey: context.apiKey,
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// 6. Process translation
|
|
128
|
+
return processTranslation(
|
|
129
|
+
docRef,
|
|
130
|
+
docData,
|
|
131
|
+
taskPayload.targetLanguage,
|
|
132
|
+
configWithKey,
|
|
133
|
+
collectionConfig,
|
|
134
|
+
{
|
|
135
|
+
forceUpdate: collectionConfig.forceUpdate,
|
|
136
|
+
}
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Create task payload for enqueueing.
|
|
142
|
+
*
|
|
143
|
+
* @param collectionPath - Collection path
|
|
144
|
+
* @param documentId - Document ID
|
|
145
|
+
* @param targetLanguage - Target locale
|
|
146
|
+
* @param config - Collection configuration
|
|
147
|
+
* @param batchId - Batch identifier
|
|
148
|
+
* @returns Task payload
|
|
149
|
+
*/
|
|
150
|
+
export function createTaskPayload(
|
|
151
|
+
collectionPath: string,
|
|
152
|
+
documentId: string,
|
|
153
|
+
targetLanguage: string,
|
|
154
|
+
config: import('@melaka/core').CollectionConfig,
|
|
155
|
+
batchId: string
|
|
156
|
+
): TranslationTaskPayload {
|
|
157
|
+
return {
|
|
158
|
+
collectionPath,
|
|
159
|
+
documentId,
|
|
160
|
+
targetLanguage,
|
|
161
|
+
config,
|
|
162
|
+
batchId,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"forceConsistentCasingInFileNames": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"declarationMap": true,
|
|
13
|
+
"sourceMap": true,
|
|
14
|
+
"outDir": "./dist",
|
|
15
|
+
"rootDir": "./src"
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/**/*"],
|
|
18
|
+
"exclude": ["node_modules", "dist"]
|
|
19
|
+
}
|
package/turbo.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://turbo.build/schema.json",
|
|
3
|
+
"globalDependencies": ["**/.env.*local"],
|
|
4
|
+
"tasks": {
|
|
5
|
+
"build": {
|
|
6
|
+
"dependsOn": ["^build"],
|
|
7
|
+
"outputs": ["dist/**"]
|
|
8
|
+
},
|
|
9
|
+
"dev": {
|
|
10
|
+
"cache": false,
|
|
11
|
+
"persistent": true
|
|
12
|
+
},
|
|
13
|
+
"test": {
|
|
14
|
+
"dependsOn": ["build"],
|
|
15
|
+
"outputs": ["coverage/**"]
|
|
16
|
+
},
|
|
17
|
+
"test:watch": {
|
|
18
|
+
"cache": false,
|
|
19
|
+
"persistent": true
|
|
20
|
+
},
|
|
21
|
+
"lint": {
|
|
22
|
+
"dependsOn": ["^build"]
|
|
23
|
+
},
|
|
24
|
+
"typecheck": {
|
|
25
|
+
"dependsOn": ["^build"]
|
|
26
|
+
},
|
|
27
|
+
"clean": {
|
|
28
|
+
"cache": false
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|