diff --git a/source/Cute/Commands/Content/ContentTranslateCommand.cs b/source/Cute/Commands/Content/ContentTranslateCommand.cs index 395b010..175c9a8 100644 --- a/source/Cute/Commands/Content/ContentTranslateCommand.cs +++ b/source/Cute/Commands/Content/ContentTranslateCommand.cs @@ -15,8 +15,8 @@ using Newtonsoft.Json.Linq; using Spectre.Console; using Spectre.Console.Cli; -using System.ComponentModel; using System.Collections.Concurrent; +using System.ComponentModel; namespace Cute.Commands.Content; @@ -31,7 +31,7 @@ public class ContentTranslateCommand(IConsoleWriter console, ILogger _translatorCache = new(); private readonly List<(string entryId, Entry entry, int version)> _pendingUpdates = new(); - private Func> translate = default!; + private bool useCustomModel = false; public class Settings : ContentCommandSettings { @@ -67,6 +67,7 @@ public override async Task ExecuteCommandAsync(CommandContext context, Sett { var contentType = await GetContentTypeOrThrowError(settings.ContentTypeId); var defaultLocale = await ContentfulConnection.GetDefaultLocaleAsync(); + useCustomModel = settings.UseCustomModel; var fieldsToTranslate = contentType.Fields.Where(f => f.Localized).ToList(); if(settings.Fields?.Length > 0) @@ -157,35 +158,6 @@ public override async Task ExecuteCommandAsync(CommandContext context, Sett return -1; } - translate = settings.UseCustomModel ? - async (translator, text, from, to) => - { - try - { - var translation = await translator.TranslateWithCustomModel(text, from, to, contentTypeTranslation, glossary?[to.Iso2Code]); - return translation?.Text; - } - catch (Exception ex) - { - _console.WriteAlert($"Error translating text from {from} to {to} using {translator.GetType().Name}. Error Message: {ex.Message}"); - return null; - } - } - : - async (translator, text, from, to) => - { - try - { - var translation = await translator.Translate(text, from, to.Iso2Code, glossary?[to.Iso2Code]); - return translation?.Text; - } - catch (Exception ex) - { - _console.WriteAlert($"Error translating text from {from} to {to} using {translator.GetType().Name}. Error Message: {ex.Message}"); - return null; - } - }; - try { // Create semaphores to limit concurrent operations @@ -246,7 +218,8 @@ await ProgressBars.Instance() settings, throttler, updateSemaphore, - taskTranslate); + taskTranslate, + glossary); symbols += batchSymbols; needToPublish = needToPublish || batchNeedsPublish; @@ -278,7 +251,8 @@ await ProgressBars.Instance() settings, throttler, updateSemaphore, - taskTranslate); + taskTranslate, + glossary); symbols += batchSymbols; needToPublish = needToPublish || batchNeedsPublish; @@ -329,70 +303,6 @@ await PerformBulkOperations( } - private async Task<(string targetField, string? translatedText, bool success)> TranslateFieldAsync( - string text, - string sourceLocale, - CuteLanguage targetLanguage, - string targetField, - TranslationService tService, - TranslationService? fallbackService, - SemaphoreSlim throttler) - { - await throttler.WaitAsync(); // Wait for a slot to be available - - try - { - var translator = _translateFactory.Create(tService); - string? translatedText = null; - int retryCount = 3; - - while (retryCount > 0) - { - try - { - translatedText = await translate(translator, text, sourceLocale, targetLanguage); - if (!string.IsNullOrEmpty(translatedText)) - break; - } - catch (Exception ex) - { - if (retryCount == 1) // Only log on final attempt - _console.WriteAlert($"Error translating text: {ex.Message}"); - } - - retryCount--; - if (retryCount > 0) - await Task.Delay(1000); // Wait before retry - } - - if(string.IsNullOrEmpty(translatedText) && fallbackService != null && tService != fallbackService) - { - translator = _translateFactory.Create(fallbackService.Value); - try - { - var translation = await translator.Translate(text, sourceLocale, targetLanguage.Iso2Code); - translatedText = translation?.Text; - } - catch (Exception ex) - { - _console.WriteAlert($"Error translating text from {sourceLocale} to {targetLanguage.Iso2Code} using {translator.GetType().Name}. Error Message: {ex.Message}"); - translatedText = null; - } - } - - return (targetField, translatedText, !string.IsNullOrEmpty(translatedText)); - } - catch (Exception ex) - { - _console.WriteAlert($"Error translating text from {sourceLocale} to {targetLanguage.Iso2Code}. Error: {ex.Message}"); - return (targetField, null, false); - } - finally - { - throttler.Release(); // Always release the throttler - } - } - private async Task<(long symbols, bool needsPublish, Dictionary> failures)> ProcessEntryBatch( List> entries, EntrySerializer serializer, @@ -405,7 +315,8 @@ await PerformBulkOperations( Settings settings, SemaphoreSlim throttler, SemaphoreSlim updateSemaphore, - ProgressTask taskTranslate) + ProgressTask taskTranslate, + Dictionary>? glossary = null) { long symbols = 0; bool needsPublish = false; @@ -481,10 +392,11 @@ flatEntryTargetLocaleValue is null || if (allTranslationRequests.Count > 0) { var translationRequestsForBatch = allTranslationRequests - .Select(r => (r.text, r.sourceLocale, r.targetLanguage, r.targetField, r.service, r.fallbackService, r.requestId)) + .Select(r => (r.text, r.sourceLocale, r.targetLanguage, r.targetField, r.service, r.fallbackService, r.requestId, r.entryId)) .ToList(); - var translationResults = await BatchTranslateFields(translationRequestsForBatch, throttler); + var translationResults = await BatchTranslateFields(translationRequestsForBatch, throttler, glossary); + var resultsByEntryAndField = new Dictionary>(); // Create lookup dictionary for requests by requestId @@ -497,10 +409,14 @@ flatEntryTargetLocaleValue is null || { if (!resultsByEntryAndField.ContainsKey(request.entryId)) resultsByEntryAndField[request.entryId] = new Dictionary(); - + // Use request.targetField to ensure correct field mapping resultsByEntryAndField[request.entryId][request.targetField] = (result.translatedText, result.success); } + else + { + Console.WriteLine($"[ERROR] RequestId {result.requestId} not found in lookup!"); + } } // Process results and update entries @@ -511,7 +427,7 @@ flatEntryTargetLocaleValue is null || var entryId = entryResult.Key; var (originalEntry, flatEntry) = entryFlatData[entryId]; var entryChanged = false; - + foreach (var fieldResult in entryResult.Value) { var targetField = fieldResult.Key; @@ -580,33 +496,42 @@ private async Task UpdateEntryAsync( } } - private ITranslator GetCachedTranslator(TranslationService service) - { - return _translatorCache.GetOrAdd(service, s => _translateFactory.Create(s)); - } - - private async Task TranslateText(string text, string from, TranslationService service, CuteLanguage targetLanguage, TranslationService? fallbackService = null, Dictionary? glossary = null) + private async Task TranslateTextMultiLanguage(string text, string from, TranslationService service, List targetLanguages, TranslationService? fallbackService = null, Dictionary>? glossaries = null) { - var translator = GetCachedTranslator(service); - string? translation = null; + // Create a NEW translator instance for each call to avoid state issues with concurrent requests + // The ChatClient may have state that gets confused with concurrent requests + var translator = _translateFactory.Create(service); + TranslationResponse[]? translations = null; try { - translation = await translate(translator, text, from, targetLanguage); + if (useCustomModel) + { + // Use the multi-language translation method + translations = await translator.TranslateWithCustomModel(text, from, targetLanguages, glossaries); + } + else + { + translations = await translator.Translate(text, from, targetLanguages); + } } catch (Exception ex) { - _console.WriteAlert($"Error translating text: {ex.Message}"); + _console.WriteAlert($"Error translating text to multiple languages: {ex.Message}"); + return translations; } // Try fallback service if primary failed - if (string.IsNullOrEmpty(translation) && fallbackService != null && service != fallbackService) + if ((translations == null || translations.Length != targetLanguages.Count) && fallbackService != null && service != fallbackService) { try { - var fallbackTranslator = GetCachedTranslator(fallbackService.Value); - var response = await fallbackTranslator.Translate(text, from, targetLanguage.Iso2Code, glossary); - translation = response?.Text; + var translatedLanguages = translations?.Select(t => t.TargetLanguage).ToHashSet() ?? new HashSet(); + + var fallbackTranslator = _translateFactory.Create(fallbackService.Value); + var languageCodes = targetLanguages.Where(l => !translatedLanguages.Contains(l.Iso2Code)).Select(l => l.Iso2Code).ToArray(); + var fallBackTranslations = await fallbackTranslator.Translate(text, from, languageCodes); + translations = (fallBackTranslations ?? Array.Empty()).Concat(translations ?? Array.Empty()).ToArray(); } catch (Exception ex) { @@ -614,7 +539,7 @@ private ITranslator GetCachedTranslator(TranslationService service) } } - return translation; + return translations; } private Dictionary BuildFieldMappings(string defaultLocale, dynamic targetLocales, IEnumerable fieldsToTranslate) @@ -656,67 +581,74 @@ private async Task FlushPendingUpdates(SemaphoreSlim updateSemaphore) } private async Task> BatchTranslateFields( - List<(string text, string sourceLocale, CuteLanguage targetLanguage, string targetField, TranslationService service, TranslationService? fallbackService, string requestId)> translationRequests, - SemaphoreSlim throttler) + List<(string text, string sourceLocale, CuteLanguage targetLanguage, string targetField, TranslationService service, TranslationService? fallbackService, string requestId, string entryId)> translationRequests, + SemaphoreSlim throttler, + Dictionary>? glossaries = null) { - // Group by service and target language for batch processing + // Group by text, sourceLocale, service, entryId, and field base name to translate same field from same entry to multiple languages at once + // Important: Include entryId to avoid mixing translations between different entries with same content var groupedRequests = translationRequests - .GroupBy(r => new { r.service, r.targetLanguage.Iso2Code }) + .GroupBy(r => new { + r.text, + r.sourceLocale, + r.service, + r.fallbackService, + r.entryId, + fieldBaseName = r.targetField.Substring(0, r.targetField.LastIndexOf('.')) // Extract field name without locale + }) .ToList(); - var allResults = new List<(string targetField, string? translatedText, bool success, string requestId)>(); + var allResults = new ConcurrentBag<(string targetField, string? translatedText, bool success, string requestId)>(); + var batchTasks = new List(); foreach (var group in groupedRequests) { + // Capture loop variables to avoid closure issues var requests = group.ToList(); - var batchTasks = new List>(); + var targetLanguages = requests.Select(r => r.targetLanguage).ToList(); + var textToTranslate = group.Key.text; // Create local copy + var sourceLocaleCode = group.Key.sourceLocale; // Create local copy + var translationService = group.Key.service; // Create local copy + var fallbackTranslationService = group.Key.fallbackService; // Create local copy + + var entryIds = string.Join(", ", requests.Select(r => r.entryId).Distinct()); - // Process in smaller batches to avoid overwhelming the translation service - for (int i = 0; i < requests.Count; i += TRANSLATION_BATCH_SIZE) + // Translate to all target languages at once + batchTasks.Add(Task.Run(async () => { - var batch = requests.Skip(i).Take(TRANSLATION_BATCH_SIZE).ToList(); - - foreach (var request in batch) + await throttler.WaitAsync(); + try { - batchTasks.Add(TranslateFieldDirect( - request.text, - request.sourceLocale, - request.targetLanguage, - request.targetField, - request.service, - request.fallbackService, - request.requestId, - throttler)); + var translations = await TranslateTextMultiLanguage(textToTranslate, sourceLocaleCode, translationService, targetLanguages, fallbackTranslationService, glossaries); + + // Map translations back to request IDs - each request gets its own translation + foreach (var request in requests) + { + var translation = translations?.FirstOrDefault(t => t.TargetLanguage == request.targetLanguage.Iso2Code); + var translatedText = translation?.Text; + + // Use ConcurrentBag to avoid lock contention + allResults.Add((request.targetField, translatedText, !string.IsNullOrEmpty(translatedText), request.requestId)); + } } - } - - var batchResults = await Task.WhenAll(batchTasks); - allResults.AddRange(batchResults); + catch (Exception ex) + { + _console.WriteAlert($"Error in multi-language translation: {ex.Message}"); + + // Add failed results for all requests in this group + foreach (var request in requests) + { + allResults.Add((request.targetField, null, false, request.requestId)); + } + } + finally + { + throttler.Release(); + } + })); } - return allResults; - } - - private async Task<(string targetField, string? translatedText, bool success, string requestId)> TranslateFieldDirect( - string text, - string sourceLocale, - CuteLanguage targetLanguage, - string targetField, - TranslationService service, - TranslationService? fallbackService, - string requestId, - SemaphoreSlim throttler) - { - await throttler.WaitAsync(); - - try - { - var translatedText = await TranslateText(text, sourceLocale, service, targetLanguage, fallbackService); - return (targetField, translatedText, !string.IsNullOrEmpty(translatedText), requestId); - } - finally - { - throttler.Release(); - } + await Task.WhenAll(batchTasks); + return allResults.ToList(); } } diff --git a/source/Cute/Services/Translation/AzureOpenAiTranslator.cs b/source/Cute/Services/Translation/AzureOpenAiTranslator.cs index 05d7894..2902f7a 100644 --- a/source/Cute/Services/Translation/AzureOpenAiTranslator.cs +++ b/source/Cute/Services/Translation/AzureOpenAiTranslator.cs @@ -1,4 +1,4 @@ -using Azure.AI.OpenAI; +using Azure.AI.OpenAI; using Cute.Config; using Cute.Lib.AiModels; using Cute.Lib.AzureOpenAi; @@ -6,12 +6,17 @@ using Cute.Services.Translation.Interfaces; using OpenAI.Chat; using System.ClientModel; +using System.Diagnostics; using System.Text; +using System.Text.Json; namespace Cute.Services.Translation { public class AzureOpenAiTranslator : ITranslator { + private const int DEFAULT_TIMEOUT_SECONDS = 120; + private const int MULTI_LANGUAGE_TIMEOUT_SECONDS = 300; // 5 minutes for multi-language + private readonly ChatCompletionOptions _defaultChatCompletionOptions = new ChatCompletionOptions() { MaxOutputTokenCount = 4096, @@ -21,7 +26,7 @@ public class AzureOpenAiTranslator : ITranslator TopP = 0.85f }; - private readonly ChatCompletionOptions _thresholdChatCompletionOptions = new ChatCompletionOptions(); + private readonly ChatCompletionOptions _thresholdChatCompletionOptions = new ChatCompletionOptions() { ResponseFormat = ChatResponseFormat.CreateJsonObjectFormat() }; private readonly ChatClient _chatClient; private readonly AzureOpenAiOptions _azureOpenAiOptions; @@ -50,95 +55,282 @@ public AzureOpenAiTranslator(IAzureOpenAiOptionsProvider azureOpenAiOptionsProvi public async Task Translate(string textToTranslate, string fromLanguageCode, string toLanguageCode, CuteContentTypeTranslation? cuteContentTypeTranslation, Dictionary? glossary = null) { - TranslationResponse result = new TranslationResponse - { - Text = await GeneratePromptAndTranslate(textToTranslate, fromLanguageCode, toLanguageCode, null, cuteContentTypeTranslation?.TranslationContext, glossary), - TargetLanguage = toLanguageCode - }; - - return result; + // Single glossary for single language + var glossaries = glossary != null ? new Dictionary> { { toLanguageCode, glossary } } : null; + var results = await GeneratePromptAndTranslate(textToTranslate, fromLanguageCode, new[] { toLanguageCode }, null, cuteContentTypeTranslation?.TranslationContext, glossaries); + return results?.FirstOrDefault(); } public async Task Translate(string textToTranslate, string fromLanguageCode, IEnumerable toLanguageCodes, CuteContentTypeTranslation? cuteContentTypeTranslation) { - var results = new List(); - foreach (var languageCode in toLanguageCodes) - { - var translation = await Translate(textToTranslate, fromLanguageCode, languageCode, cuteContentTypeTranslation); - results.Add(translation!); - } - - return results.ToArray(); + return await GeneratePromptAndTranslate(textToTranslate, fromLanguageCode, toLanguageCodes, null, cuteContentTypeTranslation?.TranslationContext, null); } - public async Task TranslateWithCustomModel(string textToTranslate, string fromLanguageCode, IEnumerable toLanguages) + public async Task Translate(string textToTranslate, string fromLanguageCode, IEnumerable toLanguages, Dictionary>? glossaries = null) { - List results = new(); - foreach (var toLanguage in toLanguages) - { - var translation = await TranslateWithCustomModel(textToTranslate, fromLanguageCode, toLanguage, null); - results.Add(translation!); - } + var firstLanguage = toLanguages.FirstOrDefault(); + if (firstLanguage == null) return Array.Empty(); - return results.ToArray(); + var languageCodes = toLanguages.Select(l => l.Iso2Code); + return await GeneratePromptAndTranslate(textToTranslate, fromLanguageCode, languageCodes, null, null, glossaries, firstLanguage.SymbolCountThreshold, firstLanguage.ThresholdSetting); + } + + public async Task TranslateWithCustomModel(string textToTranslate, string fromLanguageCode, IEnumerable toLanguages, Dictionary>? glossaries = null) + { + var firstLanguage = toLanguages.FirstOrDefault(); + if (firstLanguage == null) return Array.Empty(); + + var languageCodes = toLanguages.Select(l => l.Iso2Code); + return await GeneratePromptAndTranslate(textToTranslate, fromLanguageCode, languageCodes, firstLanguage.TranslationContext, null, glossaries, firstLanguage.SymbolCountThreshold, firstLanguage.ThresholdSetting); } public async Task TranslateWithCustomModel(string textToTranslate, string fromLanguageCode, CuteLanguage toLanguage, Dictionary? glossary = null) { - return await TranslateWithCustomModel(textToTranslate, fromLanguageCode, toLanguage, null); + return await TranslateWithCustomModel(textToTranslate, fromLanguageCode, toLanguage, null, glossary); } public async Task TranslateWithCustomModel(string textToTranslate, string fromLanguageCode, CuteLanguage toLanguage, CuteContentTypeTranslation? cuteContentTypeTranslation, Dictionary? glossary = null) { - TranslationResponse result = new TranslationResponse - { - Text = await GeneratePromptAndTranslate(textToTranslate, fromLanguageCode, toLanguage.Iso2Code, toLanguage.TranslationContext, cuteContentTypeTranslation?.TranslationContext, glossary, toLanguage.SymbolCountThreshold, toLanguage.ThresholdSetting), - TargetLanguage = toLanguage.Iso2Code - }; - - return result; + // Single glossary for single language + var glossaries = glossary != null ? new Dictionary> { { toLanguage.Iso2Code, glossary } } : null; + var results = await GeneratePromptAndTranslate(textToTranslate, fromLanguageCode, new[] { toLanguage.Iso2Code }, toLanguage.TranslationContext, cuteContentTypeTranslation?.TranslationContext, glossaries, toLanguage.SymbolCountThreshold, toLanguage.ThresholdSetting); + return results?.FirstOrDefault(); } - private async Task GeneratePromptAndTranslate(string textToTranslate, string fromLanguageCode, string toLanguageCode, string? languagePrompt, string? contentTypePrompt, Dictionary? glossary, int? symbolCountThreshold = null, string? thresholdSetting = null) + private async Task GeneratePromptAndTranslate(string textToTranslate, string fromLanguageCode, IEnumerable toLanguageCodes, string? languagePrompt, string? contentTypePrompt, Dictionary>? glossaries, int? symbolCountThreshold = null, string? thresholdSetting = null) { - (var chatClient, var chatCompletionOptions) = GetChatClient(textToTranslate, symbolCountThreshold, thresholdSetting); + var symbolCount = textToTranslate.Length; + var toLanguageCodesArray = toLanguageCodes.ToArray(); + var targetLanguagesStr = string.Join(", ", toLanguageCodesArray); + + // Check if we should translate one-by-one: using threshold model (GPT-4o) with multiple languages + // When text >= symbolCountThreshold, we use GPT-4o which has limited output tokens, so translate one by one + var isUsingThresholdModel = symbolCountThreshold.HasValue && !string.IsNullOrEmpty(thresholdSetting) && textToTranslate.Length >= symbolCountThreshold; + var shouldTranslateOneByOne = isUsingThresholdModel && toLanguageCodesArray.Length > 1; + + if (shouldTranslateOneByOne) + { + // TODO: Revisit this to refactor + // Translate each language separately to avoid output token limits with GPT-4o + return await TranslateOneByOne(textToTranslate, fromLanguageCode, toLanguageCodesArray, languagePrompt, contentTypePrompt, glossaries, symbolCountThreshold, thresholdSetting); + } + + (var chatClient, var chatCompletionOptions) = GetChatClient(textToTranslate, symbolCountThreshold, thresholdSetting, toLanguageCodesArray.Length); List messages = []; + + // Add strict JSON-only instruction + messages.Add(new SystemChatMessage("You are a translation API that ONLY outputs valid JSON. Never include explanations, markdown, or any text outside the JSON object.")); + var systemMessageText = $"{languagePrompt} {contentTypePrompt}"; if (!string.IsNullOrEmpty(systemMessageText.Trim())) { messages.Add(new SystemChatMessage(systemMessageText)); } - if (glossary != null && glossary.Count > 0) + // Add glossaries for all target languages + if (glossaries != null && glossaries.Count > 0) { - messages.Add(new SystemChatMessage($"Consider the following glossary ({fromLanguageCode}:{toLanguageCode}) when translating:\n{string.Join('\n', glossary.Select(x => $"{x.Key} : {x.Value}"))}")); + var glossaryText = new StringBuilder(); + foreach (var targetLang in toLanguageCodesArray) + { + if (glossaries.TryGetValue(targetLang, out var glossary) && glossary.Count > 0) + { + glossaryText.AppendLine($"\nGlossary for {fromLanguageCode} -> {targetLang}:"); + foreach (var term in glossary) + { + glossaryText.AppendLine($" {term.Key} : {term.Value}"); + } + } + } + + if (glossaryText.Length > 0) + { + messages.Add(new SystemChatMessage($"Consider the following glossaries when translating:{glossaryText}")); + } } - messages.Add(new UserChatMessage($"Translate text from language {fromLanguageCode} to language {toLanguageCode}. Text: {textToTranslate}")); + // Create a more strict prompt that emphasizes JSON-only output + var userPrompt = $@"Translate the text from {fromLanguageCode} to these languages: {targetLanguagesStr}. +IMPORTANT: Return ONLY a valid JSON object with no additional text, explanation, or markdown. +Format: {{""locale"":""translation""}} + +Text to translate: +{textToTranslate}"; + + messages.Add(new UserChatMessage(userPrompt)); + + // Calculate timeout based on number of languages and text length + var timeoutSeconds = toLanguageCodesArray.Length > 1 ? MULTI_LANGUAGE_TIMEOUT_SECONDS : DEFAULT_TIMEOUT_SECONDS; + var timeout = TimeSpan.FromSeconds(timeoutSeconds); + StringBuilder sb = new(); - await foreach (var part in chatClient.CompleteChatStreamingAsync(messages, chatCompletionOptions)) + using var cts = new CancellationTokenSource(timeout); + + try { - if (part == null || part.ToString() == null) continue; + await foreach (var part in chatClient.CompleteChatStreamingAsync(messages, chatCompletionOptions).WithCancellation(cts.Token)) + { + if (part == null || part.ToString() == null) continue; - foreach (var token in part.ContentUpdate) + foreach (var token in part.ContentUpdate) + { + sb.Append(token.Text); + } + } + } + catch (OperationCanceledException) + { + throw new TimeoutException($"Translation request timed out after {timeoutSeconds} seconds for {toLanguageCodesArray.Length} language(s) with {symbolCount} characters."); + } + + var jsonResponse = sb.ToString(); + var results = ParseTranslationResponse(jsonResponse, toLanguageCodesArray); + + return results; + } + + private async Task TranslateOneByOne(string textToTranslate, string fromLanguageCode, string[] toLanguageCodes, string? languagePrompt, string? contentTypePrompt, Dictionary>? glossaries, int? symbolCountThreshold, string? thresholdSetting) + { + var results = new List(); + + foreach (var targetLanguage in toLanguageCodes) + { + // Extract glossary for this specific language + Dictionary? glossary = null; + if (glossaries != null && glossaries.TryGetValue(targetLanguage, out var langGlossary)) { - sb.Append(token.Text); + glossary = langGlossary; + } + + var singleResult = await TranslateSingleLanguage(textToTranslate, fromLanguageCode, targetLanguage, languagePrompt, contentTypePrompt, glossary, symbolCountThreshold, thresholdSetting); + + if (singleResult != null) + { + results.Add(singleResult); } } + + return results.ToArray(); + } + + private async Task TranslateSingleLanguage(string textToTranslate, string fromLanguageCode, string toLanguageCode, string? languagePrompt, string? contentTypePrompt, Dictionary? glossary, int? symbolCountThreshold, string? thresholdSetting) + { + (var chatClient, var chatCompletionOptions) = GetChatClient(textToTranslate, symbolCountThreshold, thresholdSetting, 1); + + List messages = []; + messages.Add(new SystemChatMessage("You are a translation API that ONLY outputs valid JSON. Never include explanations, markdown, or any text outside the JSON object.")); + + var systemMessageText = $"{languagePrompt} {contentTypePrompt}"; + if (!string.IsNullOrEmpty(systemMessageText.Trim())) + { + messages.Add(new SystemChatMessage(systemMessageText)); + } + + if (glossary != null && glossary.Count > 0) + { + messages.Add(new SystemChatMessage($"Consider the following glossary ({fromLanguageCode}:{toLanguageCode}) when translating:\n{string.Join('\n', glossary.Select(x => $"{x.Key} : {x.Value}"))}")); + } + + var userPrompt = $@"Translate the text from {fromLanguageCode} to {toLanguageCode}. - return sb.ToString(); +IMPORTANT: Return ONLY a valid JSON object with no additional text. +Format: {{""{toLanguageCode}"":""translation""}} + +Text to translate: +{textToTranslate}"; + + messages.Add(new UserChatMessage(userPrompt)); + + StringBuilder sb = new(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(DEFAULT_TIMEOUT_SECONDS)); + + try + { + await foreach (var part in chatClient.CompleteChatStreamingAsync(messages, chatCompletionOptions).WithCancellation(cts.Token)) + { + if (part == null) continue; + foreach (var token in part.ContentUpdate) + { + sb.Append(token.Text); + } + } + } + catch (OperationCanceledException) + { + return null; + } + + var jsonResponse = sb.ToString(); + var parsedResult = ParseTranslationResponse(jsonResponse, [toLanguageCode]); + + return parsedResult?.Length > 0 ? parsedResult[0] : null; } - private (ChatClient, ChatCompletionOptions) GetChatClient(string textToTranslate, int? symbolCountThreshold, string? thresholdSetting) + private (ChatClient, ChatCompletionOptions) GetChatClient(string textToTranslate, int? symbolCountThreshold, string? thresholdSetting, int languageCount = 1) { - if(symbolCountThreshold.HasValue && !string.IsNullOrEmpty(thresholdSetting) && textToTranslate.Length < symbolCountThreshold) + ChatCompletionOptions options; + ChatClient client; + + if(symbolCountThreshold.HasValue && !string.IsNullOrEmpty(thresholdSetting) && textToTranslate.Length <= symbolCountThreshold) { - return (_azureOpenAIClient.GetChatClient(thresholdSetting), _thresholdChatCompletionOptions); + client = _azureOpenAIClient.GetChatClient(thresholdSetting); + options = _thresholdChatCompletionOptions; + } + else + { + client = _chatClient; + options = _defaultChatCompletionOptions; + } + + return (client, options); + } + + private TranslationResponse[]? ParseTranslationResponse(string jsonResponse, string[] targetLanguages) + { + try + { + // Try to extract JSON from the response (in case AI adds extra text) + var jsonStart = jsonResponse.IndexOf('{'); + var jsonEnd = jsonResponse.LastIndexOf('}'); + + if (jsonStart >= 0 && jsonEnd > jsonStart) + { + var jsonContent = jsonResponse.Substring(jsonStart, jsonEnd - jsonStart + 1); + var translations = JsonSerializer.Deserialize>(jsonContent, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + if (translations != null) + { + // Validate that we got translations for ALL expected languages + var results = new List(); + foreach (var targetLanguage in targetLanguages) + { + if (translations.TryGetValue(targetLanguage, out var translatedText) && !string.IsNullOrEmpty(translatedText) && targetLanguage != "ka") + { + results.Add(new TranslationResponse + { + Text = translatedText, + TargetLanguage = targetLanguage + }); + } + } + + return results.ToArray(); + } + } + + // Invalid JSON format or missing translations - return empty array + return Array.Empty(); + } + catch (Exception) + { + // Parsing failed - return empty array + return Array.Empty(); } - - return (_chatClient, _defaultChatCompletionOptions); } } } diff --git a/source/Cute/Services/Translation/AzureTranslator.cs b/source/Cute/Services/Translation/AzureTranslator.cs index b909c83..7b9052c 100644 --- a/source/Cute/Services/Translation/AzureTranslator.cs +++ b/source/Cute/Services/Translation/AzureTranslator.cs @@ -57,9 +57,24 @@ public AzureTranslator(AppSettings settings, HttpClient httpClient) return await Translate(textToTranslate, fromLanguageCode, toLanguageCodes); } - public Task TranslateWithCustomModel(string textToTranslate, string fromLanguageCode, IEnumerable toLanguages) + public async Task Translate(string textToTranslate, string fromLanguageCode, IEnumerable toLanguages, Dictionary>? glossaries = null) { - throw new NotImplementedException(); + return await Translate(textToTranslate, fromLanguageCode, toLanguages.Select(k => k.Iso2Code)); + } + + public async Task TranslateWithCustomModel(string textToTranslate, string fromLanguageCode, IEnumerable toLanguages, Dictionary>? glossaries = null) + { + List result = new List(); + foreach (var language in toLanguages) + { + var translation = await TranslateWithCustomModel(textToTranslate, fromLanguageCode, language, glossaries != null && glossaries.ContainsKey(language.Iso2Code) ? glossaries[language.Iso2Code] : null); + if (translation != null) + { + result.Add(translation); + } + } + + return result.ToArray(); } public async Task TranslateWithCustomModel(string textToTranslate, string fromLanguageCode, CuteLanguage toLanguage, Dictionary? glossary = null) diff --git a/source/Cute/Services/Translation/DeeplTranslator.cs b/source/Cute/Services/Translation/DeeplTranslator.cs index 0be4601..fab8f3b 100644 --- a/source/Cute/Services/Translation/DeeplTranslator.cs +++ b/source/Cute/Services/Translation/DeeplTranslator.cs @@ -50,7 +50,12 @@ public DeeplTranslator(AppSettings appSettings) return await Translate(textToTranslate, fromLanguageCode, toLanguageCodes); } - public Task TranslateWithCustomModel(string textToTranslate, string fromLanguageCode, IEnumerable toLanguages) + public async Task Translate(string textToTranslate, string fromLanguageCode, IEnumerable toLanguages, Dictionary>? glossaries = null) + { + return await Translate(textToTranslate, fromLanguageCode, toLanguages.Select(k => k.Iso2Code)); + } + + public Task TranslateWithCustomModel(string textToTranslate, string fromLanguageCode, IEnumerable toLanguages, Dictionary>? glossaries = null) { throw new NotImplementedException(); } diff --git a/source/Cute/Services/Translation/GoogleTranslator.cs b/source/Cute/Services/Translation/GoogleTranslator.cs index a4b4e55..a46ce33 100644 --- a/source/Cute/Services/Translation/GoogleTranslator.cs +++ b/source/Cute/Services/Translation/GoogleTranslator.cs @@ -51,7 +51,12 @@ public GoogleTranslator(AppSettings appSettings) return await Translate(textToTranslate, fromLanguageCode, toLanguageCodes); } - public Task TranslateWithCustomModel(string textToTranslate, string fromLanguageCode, IEnumerable toLanguages) + public async Task Translate(string textToTranslate, string fromLanguageCode, IEnumerable toLanguages, Dictionary>? glossaries = null) + { + return await Translate(textToTranslate, fromLanguageCode, toLanguages.Select(k => k.Iso2Code)); + } + + public Task TranslateWithCustomModel(string textToTranslate, string fromLanguageCode, IEnumerable toLanguages, Dictionary>? glossaries = null) { throw new NotImplementedException(); } diff --git a/source/Cute/Services/Translation/Interfaces/ITranslator.cs b/source/Cute/Services/Translation/Interfaces/ITranslator.cs index 1c3bc78..fcc2c02 100644 --- a/source/Cute/Services/Translation/Interfaces/ITranslator.cs +++ b/source/Cute/Services/Translation/Interfaces/ITranslator.cs @@ -8,7 +8,8 @@ public interface ITranslator Task Translate(string textToTranslate, string fromLanguageCode, IEnumerable toLanguageCodes, CuteContentTypeTranslation? cuteContentTypeTranslation); Task Translate(string textToTranslate, string fromLanguageCode, string toLanguageCode, Dictionary? glossary = null); Task Translate(string textToTranslate, string fromLanguageCode, string toLanguageCode, CuteContentTypeTranslation? cuteContentTypeTranslation, Dictionary? glossary = null); - Task TranslateWithCustomModel(string textToTranslate, string fromLanguageCode, IEnumerable toLanguages); + Task Translate(string textToTranslate, string fromLanguageCode, IEnumerable toLanguages, Dictionary>? glossaries = null); + Task TranslateWithCustomModel(string textToTranslate, string fromLanguageCode, IEnumerable toLanguages, Dictionary>? glossaries = null); Task TranslateWithCustomModel(string textToTranslate, string fromLanguageCode, CuteLanguage toLanguage, Dictionary? glossary = null); Task TranslateWithCustomModel(string textToTranslate, string fromLanguageCode, CuteLanguage toLanguage, CuteContentTypeTranslation? cuteContentTypeTranslation, Dictionary? glossary = null);