locai 2.1.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.
Files changed (72) hide show
  1. package/README.md +928 -0
  2. package/dist/cli.js +718 -0
  3. package/dist/commands/review.js +357 -0
  4. package/dist/commands/translator.js +29 -0
  5. package/dist/config/index.js +52 -0
  6. package/dist/core/adapters/base-adapter.js +1 -0
  7. package/dist/core/adapters/factory.js +44 -0
  8. package/dist/core/adapters/json-adapter.js +20 -0
  9. package/dist/core/adapters/po-adapter.js +50 -0
  10. package/dist/core/adapters/properties-adapter.js +31 -0
  11. package/dist/core/adapters/yaml-adapter.js +24 -0
  12. package/dist/core/context-processor.js +247 -0
  13. package/dist/core/detection/framework-detector.js +56 -0
  14. package/dist/core/detection/structure-detector.js +30 -0
  15. package/dist/core/fallback-provider.js +442 -0
  16. package/dist/core/orchestrator.js +438 -0
  17. package/dist/core/pipeline/Pipeline.js +48 -0
  18. package/dist/core/pipeline/PipelineStep.js +13 -0
  19. package/dist/core/pipeline/context.js +21 -0
  20. package/dist/core/pipeline/steps/CacheReadStep.js +80 -0
  21. package/dist/core/pipeline/steps/CacheWriteStep.js +32 -0
  22. package/dist/core/pipeline/steps/ConfidenceCheckStep.js +67 -0
  23. package/dist/core/pipeline/steps/GlossaryPostStep.js +17 -0
  24. package/dist/core/pipeline/steps/GlossaryPreStep.js +20 -0
  25. package/dist/core/pipeline/steps/InputValidationStep.js +26 -0
  26. package/dist/core/pipeline/steps/QualityCheckStep.js +48 -0
  27. package/dist/core/pipeline/steps/ToneCheckStep.js +58 -0
  28. package/dist/core/pipeline/steps/TranslationStep.js +65 -0
  29. package/dist/core/pipeline/steps/VectorCacheReadStep.js +84 -0
  30. package/dist/core/pipeline/steps/VectorCacheWriteStep.js +42 -0
  31. package/dist/core/pipeline/steps/__tests__/ToneCheckStep.test.js +100 -0
  32. package/dist/core/provider-factory.js +139 -0
  33. package/dist/providers/base-provider.js +230 -0
  34. package/dist/providers/dashscope.js +128 -0
  35. package/dist/providers/deepseek.js +111 -0
  36. package/dist/providers/gemini.js +135 -0
  37. package/dist/providers/openai.js +203 -0
  38. package/dist/providers/xai.js +111 -0
  39. package/dist/services/embedding-provider.js +50 -0
  40. package/dist/services/translation-service.js +609 -0
  41. package/dist/services/vector-store.js +79 -0
  42. package/dist/utils/ai-context-analyzer.js +460 -0
  43. package/dist/utils/confidence-scorer.js +277 -0
  44. package/dist/utils/error-helper.js +386 -0
  45. package/dist/utils/file-manager.js +357 -0
  46. package/dist/utils/glossary-manager.js +194 -0
  47. package/dist/utils/graceful-shutdown.js +118 -0
  48. package/dist/utils/input-validator.js +663 -0
  49. package/dist/utils/logger.js +374 -0
  50. package/dist/utils/object-transformer.js +177 -0
  51. package/dist/utils/progress-tracker.js +277 -0
  52. package/dist/utils/prompt-templates.js +415 -0
  53. package/dist/utils/quality/__tests__/tone-verifier.test.js +91 -0
  54. package/dist/utils/quality/base-checker.js +73 -0
  55. package/dist/utils/quality/code-block-checker.js +214 -0
  56. package/dist/utils/quality/html-tag-checker.js +64 -0
  57. package/dist/utils/quality/index.js +220 -0
  58. package/dist/utils/quality/length-checker.js +140 -0
  59. package/dist/utils/quality/markdown-checker.js +220 -0
  60. package/dist/utils/quality/placeholder-checker.js +142 -0
  61. package/dist/utils/quality/punctuation-checker.js +50 -0
  62. package/dist/utils/quality/quote-balance-checker.js +143 -0
  63. package/dist/utils/quality/special-characters-checker.js +229 -0
  64. package/dist/utils/quality/style-guide-checker.js +192 -0
  65. package/dist/utils/quality/text-sanitizer.js +120 -0
  66. package/dist/utils/quality/tone-verifier.js +91 -0
  67. package/dist/utils/rate-limiter.js +398 -0
  68. package/dist/utils/retry-helper.js +166 -0
  69. package/dist/utils/state-manager.js +162 -0
  70. package/dist/utils/statistics-manager.js +51 -0
  71. package/dist/utils/ui-manager.js +98 -0
  72. package/package.json +80 -0
@@ -0,0 +1,460 @@
1
+ import ProviderFactory from "../core/provider-factory.js";
2
+ import rateLimiter from "./rate-limiter.js";
3
+ import { LRUCache } from "lru-cache";
4
+ import crypto from "crypto";
5
+ import { log } from "./logger.js";
6
+ class AIContextAnalyzer {
7
+ config;
8
+ cache;
9
+ categoryKeywords;
10
+ stats;
11
+ constructor(config) {
12
+ this.config = config;
13
+ this.cache = new LRUCache({
14
+ max: config.cacheSize || 500,
15
+ ttl: config.cacheTTL || 1000 * 60 * 60 * 24,
16
+ updateAgeOnGet: true,
17
+ allowStale: true,
18
+ });
19
+ this.categoryKeywords = {};
20
+ if (config.categories) {
21
+ Object.entries(config.categories).forEach(([category, data]) => {
22
+ if (data.keywords && Array.isArray(data.keywords)) {
23
+ this.categoryKeywords[category] = new Set(data.keywords.map((k) => k.toLowerCase()));
24
+ }
25
+ });
26
+ }
27
+ this.stats = {
28
+ totalAnalyzed: 0,
29
+ aiCalls: 0,
30
+ cacheHits: 0,
31
+ keywordMatches: 0,
32
+ newCategories: 0,
33
+ errors: 0,
34
+ };
35
+ }
36
+ // Batch analysis for multiple texts - much more efficient
37
+ async analyzeBatch(texts, apiProvider = "openai") {
38
+ if (!texts || !Array.isArray(texts) || !this.config.enabled) {
39
+ return texts.map(() => null);
40
+ }
41
+ const results = [];
42
+ const aiTexts = [];
43
+ const aiIndices = [];
44
+ // First pass: keyword matching and cache lookup
45
+ for (let i = 0; i < texts.length; i++) {
46
+ const text = texts[i];
47
+ if (!text) {
48
+ results[i] = null;
49
+ continue;
50
+ }
51
+ if (text.length < (this.config.minTextLength || 10)) {
52
+ results[i] = this._fastKeywordMatch(text);
53
+ continue;
54
+ }
55
+ const cacheKey = this._getCacheKey(text);
56
+ if (this.cache.has(cacheKey)) {
57
+ this.stats.cacheHits++;
58
+ results[i] = this.cache.get(cacheKey) || null;
59
+ continue;
60
+ }
61
+ const keywordMatch = this._fastKeywordMatch(text);
62
+ if (keywordMatch && keywordMatch.confidence > 0.85) {
63
+ this.stats.keywordMatches++;
64
+ this.cache.set(cacheKey, keywordMatch);
65
+ results[i] = keywordMatch;
66
+ continue;
67
+ }
68
+ // Need AI analysis
69
+ if (this.config.useAI) {
70
+ aiTexts.push(text);
71
+ aiIndices.push(i);
72
+ results[i] = keywordMatch || this._getDefaultContext();
73
+ }
74
+ else {
75
+ results[i] = keywordMatch || this._getDefaultContext();
76
+ }
77
+ }
78
+ // Batch AI analysis if needed
79
+ if (aiTexts.length > 0 && this.config.useAI) {
80
+ try {
81
+ const batchResults = await this._batchAIAnalysis(aiTexts, apiProvider);
82
+ for (let i = 0; i < batchResults.length; i++) {
83
+ const originalIndex = aiIndices[i];
84
+ const aiResult = batchResults[i];
85
+ if (aiResult) {
86
+ results[originalIndex] = aiResult;
87
+ const cacheKey = this._getCacheKey(aiTexts[i]);
88
+ this.cache.set(cacheKey, aiResult);
89
+ }
90
+ }
91
+ }
92
+ catch (error) {
93
+ console.error("Batch AI analysis failed:", error.message);
94
+ }
95
+ }
96
+ return results;
97
+ }
98
+ // Single text analysis (legacy support)
99
+ async analyzeContext(text, apiProvider = "openai") {
100
+ const results = await this.analyzeBatch([text], apiProvider);
101
+ return results[0];
102
+ }
103
+ // Efficient batch AI analysis
104
+ async _batchAIAnalysis(texts, apiProvider) {
105
+ if (texts.length === 0)
106
+ return [];
107
+ this.stats.totalAnalyzed += texts.length;
108
+ this.stats.aiCalls++; // Only one API call for the batch
109
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
110
+ const provider = ProviderFactory.getProvider(apiProvider, true, this.config);
111
+ if (!provider) {
112
+ throw new Error("AI provider not available for context analysis");
113
+ }
114
+ const batchPrompt = this._createBatchAnalysisPrompt(texts);
115
+ const result = await rateLimiter.enqueue(apiProvider.toLowerCase(), () => provider.analyze(batchPrompt, {
116
+ ...this.config.analysisOptions,
117
+ maxTokens: Math.min(4000, this.config.analysisOptions?.maxTokens || 2000),
118
+ timeout: this.config.analysisOptions?.timeout || 10000,
119
+ retries: this.config.analysisOptions?.retries || 2,
120
+ }));
121
+ return this._parseBatchAnalysisResult(result, texts.length);
122
+ }
123
+ // Create batch analysis prompt for multiple texts
124
+ _createBatchAnalysisPrompt(texts) {
125
+ const categories = Object.keys(this.config.categories || {}).join(", ");
126
+ const maxTextLength = 200; // Shorter for batch processing
127
+ const truncatedTexts = texts
128
+ .map((text, index) => {
129
+ const truncated = text.length > maxTextLength ? text.substring(0, maxTextLength) + "..." : text;
130
+ return `${index + 1}. "${truncated}"`;
131
+ })
132
+ .join("\n");
133
+ return `
134
+ TASK: Analyze the following ${texts.length} texts and determine their context categories. Be concise.
135
+
136
+ TEXTS TO ANALYZE:
137
+ ${truncatedTexts}
138
+
139
+ AVAILABLE CATEGORIES: ${categories}
140
+
141
+ INSTRUCTIONS:
142
+ 1. For each text, identify the primary context category
143
+ 2. Provide a confidence score (0.0-1.0)
144
+ 3. Be concise - this is batch processing
145
+
146
+ FORMAT YOUR RESPONSE AS JSON ARRAY:
147
+ [
148
+ {"index": 1, "category": "category_name", "confidence": 0.0-1.0},
149
+ {"index": 2, "category": "category_name", "confidence": 0.0-1.0},
150
+ ...
151
+ ]
152
+ `;
153
+ }
154
+ _createAnalysisPrompt(text) {
155
+ const categories = Object.keys(this.config.categories || {}).join(", ");
156
+ const maxTextLength = this.config.analysisOptions?.maxTokens
157
+ ? Math.min(1500, this.config.analysisOptions.maxTokens * 5)
158
+ : 1500;
159
+ const truncatedText = text.length > maxTextLength ? text.substring(0, maxTextLength) + "..." : text;
160
+ return `
161
+ TASK: Analyze the following text and determine its context category.
162
+
163
+ TEXT TO ANALYZE:
164
+ """
165
+ ${truncatedText}
166
+ """
167
+
168
+ AVAILABLE CATEGORIES: ${categories}${this.config.allowNewCategories ? ", or suggest a new category if none of these fit" : ""}
169
+
170
+ INSTRUCTIONS:
171
+ 1. Identify the primary context category of the text
172
+ 2. Provide a confidence score (0.0-1.0)
173
+ 3. Suggest 3-5 keywords that are relevant to this text
174
+ 4. Provide a brief explanation of your categorization
175
+
176
+ FORMAT YOUR RESPONSE AS JSON:
177
+ {
178
+ "category": "category_name",
179
+ "confidence": 0.0-1.0,
180
+ "keywords": ["keyword1", "keyword2", "keyword3"],
181
+ "explanation": "Brief explanation of why this category was chosen"
182
+ }
183
+ `;
184
+ }
185
+ _createSimplifiedAnalysisPrompt(text) {
186
+ const categories = Object.keys(this.config.categories || {}).join(", ");
187
+ return `
188
+ TASK: Categorize the following text. Be concise.
189
+
190
+ TEXT: "${text}"
191
+
192
+ CATEGORIES: ${categories}${this.config.allowNewCategories ? " (or suggest new)" : ""}
193
+
194
+ RESPONSE FORMAT:
195
+ {
196
+ "category": "category_name",
197
+ "confidence": 0.0-1.0,
198
+ "keywords": ["keyword1", "keyword2", "keyword3"]
199
+ }
200
+ `;
201
+ }
202
+ // Parse batch analysis results
203
+ _parseBatchAnalysisResult(result, expectedCount) {
204
+ try {
205
+ const jsonMatch = result.match(/\[[\s\S]*?\]/);
206
+ if (!jsonMatch) {
207
+ throw new Error("No valid JSON array found in AI response");
208
+ }
209
+ let analysisArray;
210
+ try {
211
+ analysisArray = JSON.parse(jsonMatch[0]);
212
+ }
213
+ catch (jsonError) {
214
+ console.error("JSON parsing error:", jsonError);
215
+ const cleanedJson = jsonMatch[0]
216
+ .replace(/,\s*]/g, "]")
217
+ .replace(/([{,]\s*)(\w+)(\s*:)/g, '$1"$2"$3');
218
+ analysisArray = JSON.parse(cleanedJson);
219
+ }
220
+ if (!Array.isArray(analysisArray)) {
221
+ throw new Error("Response is not an array");
222
+ }
223
+ // Create results array with proper indexing
224
+ const results = new Array(expectedCount).fill(null);
225
+ for (const item of analysisArray) {
226
+ if (!item.category || typeof item.confidence !== "number" || !item.index) {
227
+ continue;
228
+ }
229
+ const index = item.index - 1; // Convert to 0-based index
230
+ if (index < 0 || index >= expectedCount) {
231
+ continue;
232
+ }
233
+ let category = item.category.toLowerCase().trim();
234
+ if (this.config.categories &&
235
+ !this.config.categories[category] &&
236
+ this.config.allowNewCategories) {
237
+ this._saveNewCategory(category, item.keywords || []);
238
+ this.stats.newCategories++;
239
+ }
240
+ else if (this.config.categories && !this.config.categories[category]) {
241
+ category = this._findClosestCategory(category, item.keywords || []);
242
+ }
243
+ results[index] = {
244
+ category,
245
+ confidence: item.confidence,
246
+ keywords: item.keywords || [],
247
+ explanation: item.explanation || "Batch analysis",
248
+ prompt: this.config.categories?.[category]?.prompt || this.config.fallback?.prompt,
249
+ method: "batch_ai",
250
+ };
251
+ }
252
+ return results;
253
+ }
254
+ catch (error) {
255
+ console.error("Error parsing batch AI analysis result:", error.message);
256
+ return new Array(expectedCount).fill(null);
257
+ }
258
+ }
259
+ _parseAnalysisResult(result) {
260
+ try {
261
+ const jsonMatch = result.match(/\{[\s\S]*?\}/);
262
+ if (!jsonMatch) {
263
+ throw new Error("No valid JSON found in AI response");
264
+ }
265
+ let analysisData;
266
+ try {
267
+ analysisData = JSON.parse(jsonMatch[0]);
268
+ }
269
+ catch (jsonError) {
270
+ console.error("JSON parsing error:", jsonError);
271
+ const cleanedJson = jsonMatch[0]
272
+ .replace(/,\s*}/g, "}")
273
+ .replace(/([{,]\s*)(\w+)(\s*:)/g, '$1"$2"$3');
274
+ analysisData = JSON.parse(cleanedJson);
275
+ }
276
+ if (!analysisData.category || typeof analysisData.confidence !== "number") {
277
+ throw new Error("Invalid analysis data structure");
278
+ }
279
+ let category = analysisData.category.toLowerCase().trim();
280
+ if (this.config.categories &&
281
+ !this.config.categories[category] &&
282
+ this.config.allowNewCategories) {
283
+ this._saveNewCategory(category, analysisData.keywords || []);
284
+ this.stats.newCategories++;
285
+ }
286
+ else if (this.config.categories && !this.config.categories[category]) {
287
+ category = this._findClosestCategory(category, analysisData.keywords || []);
288
+ }
289
+ return {
290
+ category,
291
+ confidence: analysisData.confidence,
292
+ keywords: analysisData.keywords || [],
293
+ explanation: analysisData.explanation || "No explanation provided",
294
+ prompt: this.config.categories?.[category]?.prompt || this.config.fallback?.prompt,
295
+ };
296
+ }
297
+ catch (error) {
298
+ console.error("Error parsing AI analysis result:", error.message);
299
+ return null;
300
+ }
301
+ }
302
+ _fastKeywordMatch(text) {
303
+ if (!text || !this.config?.categories) {
304
+ return this._getDefaultContext();
305
+ }
306
+ const textLower = text.toLowerCase();
307
+ const scores = {};
308
+ let maxScore = 0;
309
+ let bestCategory = this.config.fallback?.category || "general";
310
+ for (const [category, keywordSet] of Object.entries(this.categoryKeywords)) {
311
+ let score = 0;
312
+ for (const keyword of keywordSet) {
313
+ const regex = new RegExp(`\\b${keyword}\\b`, "i");
314
+ if (regex.test(textLower)) {
315
+ score += 2;
316
+ }
317
+ else if (textLower.includes(keyword)) {
318
+ score += 1;
319
+ }
320
+ }
321
+ if (score > 0) {
322
+ const weight = this.config.categories[category]?.weight || 1.0;
323
+ score *= weight;
324
+ scores[category] = score;
325
+ if (score > maxScore) {
326
+ maxScore = score;
327
+ bestCategory = category;
328
+ }
329
+ }
330
+ }
331
+ const maxPossibleScore = Math.max(8, Object.keys(this.categoryKeywords).length * 2);
332
+ const baseConfidence = Math.min(0.9, maxScore / maxPossibleScore);
333
+ const confidence = Math.min(0.95, 1 - Math.exp(-baseConfidence * 2));
334
+ if (this.config.detection && confidence < (this.config.detection.minConfidence || 0.4)) {
335
+ return this._getDefaultContext();
336
+ }
337
+ return {
338
+ category: bestCategory,
339
+ confidence,
340
+ keywords: Array.from(this.categoryKeywords[bestCategory] || []).slice(0, 5),
341
+ explanation: `Matched keywords for ${bestCategory} category`,
342
+ prompt: this.config.categories[bestCategory]?.prompt || this.config.fallback?.prompt,
343
+ method: "keyword_match",
344
+ };
345
+ }
346
+ _getDefaultContext() {
347
+ return {
348
+ category: this.config.fallback?.category || "general",
349
+ confidence: 0.5,
350
+ keywords: [],
351
+ explanation: "Default category used",
352
+ prompt: this.config.fallback?.prompt || "Translate naturally",
353
+ method: "default",
354
+ };
355
+ }
356
+ _findClosestCategory(suggestedCategory, keywords) {
357
+ let bestMatch = this.config.fallback?.category || "general";
358
+ let highestScore = 0;
359
+ if (!this.config.categories)
360
+ return bestMatch;
361
+ for (const [category, config] of Object.entries(this.config.categories)) {
362
+ let score = 0;
363
+ if (config.keywords && this.categoryKeywords[category]) {
364
+ const keywordsLower = new Set(keywords.map((k) => k.toLowerCase()));
365
+ for (const keyword of keywordsLower) {
366
+ if (this.categoryKeywords[category].has(keyword)) {
367
+ score += 1.5;
368
+ continue;
369
+ }
370
+ for (const configKeyword of this.categoryKeywords[category]) {
371
+ if (configKeyword.includes(keyword) || keyword.includes(configKeyword)) {
372
+ score += 0.75;
373
+ break;
374
+ }
375
+ }
376
+ }
377
+ }
378
+ if (category.includes(suggestedCategory) || suggestedCategory.includes(category)) {
379
+ score += 2;
380
+ }
381
+ if (score > highestScore) {
382
+ highestScore = score;
383
+ bestMatch = category;
384
+ }
385
+ }
386
+ return bestMatch;
387
+ }
388
+ _saveNewCategory(category, keywords) {
389
+ if (this.config.categories && !this.config.categories[category]) {
390
+ this.config.categories[category] = {
391
+ keywords: keywords,
392
+ prompt: `Translate with awareness of ${category} context`,
393
+ weight: 1.0,
394
+ };
395
+ this.categoryKeywords[category] = new Set(keywords.map((k) => k.toLowerCase()));
396
+ log(`Added new context category: ${category}`, true);
397
+ }
398
+ }
399
+ /**
400
+ * Generate a collision-resistant cache key for text
401
+ */
402
+ _getCacheKey(text) {
403
+ let keyContent;
404
+ if (text.length <= 100) {
405
+ // Short text: use as-is (lowercased for consistency)
406
+ keyContent = text.toLowerCase();
407
+ }
408
+ else if (text.length <= 500) {
409
+ // Medium text: sample from beginning, middle, and end with better distribution
410
+ const start = text.substring(0, 40).toLowerCase();
411
+ const middlePos = Math.floor(text.length / 2);
412
+ const middle = text.substring(middlePos - 20, middlePos + 20).toLowerCase();
413
+ const end = text.substring(text.length - 40).toLowerCase();
414
+ keyContent = `${start}|MID:${middle}|${end}`;
415
+ }
416
+ else {
417
+ // Long text: enhanced sampling with length and character diversity
418
+ const quarter = Math.floor(text.length / 4);
419
+ const start = text.substring(0, 30).toLowerCase();
420
+ const q1 = text.substring(quarter, quarter + 25).toLowerCase();
421
+ const q2 = text.substring(quarter * 2, quarter * 2 + 25).toLowerCase();
422
+ const q3 = text.substring(quarter * 3, quarter * 3 + 25).toLowerCase();
423
+ const end = text.substring(text.length - 30).toLowerCase();
424
+ // Include length and character diversity for additional uniqueness
425
+ const charSet = new Set(text.toLowerCase()).size;
426
+ const wordCount = text.split(/\s+/).length;
427
+ keyContent = `LEN:${text.length}|WORDS:${wordCount}|CHARS:${charSet}|${start}|Q1:${q1}|Q2:${q2}|Q3:${q3}|${end}`;
428
+ }
429
+ return crypto
430
+ .createHash("sha256")
431
+ .update(keyContent, "utf8")
432
+ .digest("hex")
433
+ .substring(0, 32); // Truncate for storage efficiency while maintaining uniqueness
434
+ }
435
+ getStats() {
436
+ return {
437
+ ...this.stats,
438
+ cacheSize: this.cache.size,
439
+ cacheCapacity: this.cache.max,
440
+ hitRate: this.stats.cacheHits / Math.max(1, this.stats.totalAnalyzed),
441
+ aiUsageRate: this.stats.aiCalls / Math.max(1, this.stats.totalAnalyzed),
442
+ keywordMatchRate: this.stats.keywordMatches / Math.max(1, this.stats.totalAnalyzed),
443
+ errorRate: this.stats.errors / Math.max(1, this.stats.totalAnalyzed),
444
+ };
445
+ }
446
+ resetStats() {
447
+ this.stats = {
448
+ totalAnalyzed: 0,
449
+ aiCalls: 0,
450
+ cacheHits: 0,
451
+ keywordMatches: 0,
452
+ newCategories: 0,
453
+ errors: 0,
454
+ };
455
+ }
456
+ clearCache() {
457
+ this.cache.clear();
458
+ }
459
+ }
460
+ export default AIContextAnalyzer;