diff --git a/Apps.AzueOpenAI/Actions/AudioActions.cs b/Apps.AzueOpenAI/Actions/AudioActions.cs index f0445d4..57c3290 100644 --- a/Apps.AzueOpenAI/Actions/AudioActions.cs +++ b/Apps.AzueOpenAI/Actions/AudioActions.cs @@ -10,15 +10,10 @@ namespace Apps.AzureOpenAI.Actions { - public class AudioActions : BaseActions + public class AudioActions(InvocationContext invocationContext, IFileManagementClient fileManagementClient) + : BaseActions(invocationContext, fileManagementClient) { - private readonly IFileManagementClient _fileManagementClient; - - public AudioActions(InvocationContext invocationContext, IFileManagementClient fileManagementClient) - : base(invocationContext) - { - _fileManagementClient = fileManagementClient; - } + private readonly IFileManagementClient _fileManagementClient = fileManagementClient; [Action("Create English translation", Description = "Generates a translation into English given an audio or " + "video file (mp3, mp4, mpeg, mpga, m4a, wav, or webm).")] diff --git a/Apps.AzueOpenAI/Actions/Base/BaseActions.cs b/Apps.AzueOpenAI/Actions/Base/BaseActions.cs index 88fb458..8454663 100644 --- a/Apps.AzueOpenAI/Actions/Base/BaseActions.cs +++ b/Apps.AzueOpenAI/Actions/Base/BaseActions.cs @@ -1,7 +1,17 @@ -using Azure; +using Apps.AzureOpenAI.Models.Dto; +using Apps.AzureOpenAI.Models.Entities; +using Apps.AzureOpenAI.Models.Requests.Chat; +using Apps.AzureOpenAI.Models.Responses.Chat; +using Azure; using Azure.AI.OpenAI; using Blackbird.Applications.Sdk.Common; +using Blackbird.Applications.Sdk.Common.Files; using Blackbird.Applications.Sdk.Common.Invocation; +using Blackbird.Applications.SDK.Extensions.FileManagement.Interfaces; +using Blackbird.Xliff.Utils; +using Blackbird.Xliff.Utils.Extensions; +using Newtonsoft.Json; +using RestSharp; namespace Apps.AzureOpenAI.Actions.Base; @@ -9,8 +19,9 @@ public class BaseActions : BaseInvocable { protected readonly OpenAIClient Client; protected readonly string DeploymentName; + protected readonly IFileManagementClient FileManagementClient; - protected BaseActions(InvocationContext invocationContext) + protected BaseActions(InvocationContext invocationContext, IFileManagementClient fileManagementClient) : base(invocationContext) { DeploymentName = InvocationContext.AuthenticationCredentialsProviders.First(x => x.KeyName == "deployment") @@ -19,5 +30,90 @@ protected BaseActions(InvocationContext invocationContext) new Uri(InvocationContext.AuthenticationCredentialsProviders.First(x => x.KeyName == "url").Value), new AzureKeyCredential(InvocationContext.AuthenticationCredentialsProviders .First(x => x.KeyName == "apiKey").Value)); + FileManagementClient = fileManagementClient; + } + + protected async Task DownloadXliffDocumentAsync(FileReference file) + { + var fileStream = await FileManagementClient.DownloadAsync(file); + var xliffMemoryStream = new MemoryStream(); + await fileStream.CopyToAsync(xliffMemoryStream); + xliffMemoryStream.Position = 0; + + var xliffDocument = xliffMemoryStream.ToXliffDocument(); + if (xliffDocument.TranslationUnits.Count == 0) + { + throw new InvalidOperationException("The XLIFF file does not contain any translation units."); + } + + return xliffDocument; + } + + protected async Task<(string result, UsageDto usage)> ExecuteOpenAIRequestAsync(ExecuteOpenAIRequestParameters parameters) + { + var restClient = new RestClient(InvocationContext.AuthenticationCredentialsProviders + .First(x => x.KeyName == "url").Value); + var request = new RestRequest("/openai/deployments/" + DeploymentName + $"/chat/completions?api-version={parameters.ApiVersion}", Method.Post); + + request.AddHeader("Content-Type", "application/json"); + request.AddHeader("api-key", InvocationContext.AuthenticationCredentialsProviders + .First(x => x.KeyName == "apiKey").Value); + + var body = new Dictionary + { + { + "messages", new List + { + new + { + role = "system", + content = parameters.SystemPrompt + }, + new + { + role = "user", + content = parameters.Prompt + } + } + }, + }; + + if(parameters.ResponseFormat != null) + { + body.Add("response_format", parameters.ResponseFormat); + } + + if(parameters.ChatRequest?.Temperature != null) + { + body.Add("temperature", parameters.ChatRequest.Temperature.Value); + } + + if(parameters.ChatRequest?.MaximumTokens != null) + { + body.Add("max_completion_tokens", parameters.ChatRequest.MaximumTokens.Value); + } + + if(parameters.ChatRequest?.PresencePenalty != null) + { + body.Add("presence_penalty", parameters.ChatRequest.PresencePenalty.Value); + } + + if(parameters.ChatRequest?.FrequencyPenalty != null) + { + body.Add("frequency_penalty", parameters.ChatRequest.FrequencyPenalty.Value); + } + + request.AddJsonBody(body); + + var response = await restClient.ExecuteAsync(request); + + if (!response.IsSuccessStatusCode) + { + var error = JsonConvert.DeserializeObject(response.Content!)!; + throw new InvalidOperationException(error.ToString()); + } + + var chatResponse = JsonConvert.DeserializeObject(response.Content!)!; + return (chatResponse.Choices.First().Message.Content, new(chatResponse.Usage)); } } \ No newline at end of file diff --git a/Apps.AzueOpenAI/Actions/ChatActions.cs b/Apps.AzueOpenAI/Actions/ChatActions.cs index a7cc1de..adb7a93 100644 --- a/Apps.AzueOpenAI/Actions/ChatActions.cs +++ b/Apps.AzueOpenAI/Actions/ChatActions.cs @@ -6,18 +6,16 @@ using Blackbird.Applications.Sdk.Common.Invocation; using Apps.AzureOpenAI.Actions.Base; using Apps.AzureOpenAI.Utils; +using Blackbird.Applications.SDK.Extensions.FileManagement.Interfaces; using Newtonsoft.Json; using TiktokenSharp; namespace Apps.AzureOpenAI.Actions; [ActionList] -public class ChatActions : BaseActions +public class ChatActions(InvocationContext invocationContext, IFileManagementClient fileManagementClient) + : BaseActions(invocationContext, fileManagementClient) { - public ChatActions(InvocationContext invocationContext) : base(invocationContext) - { - } - #region Chat actions [Action("Generate completion", Description = "Completes the given prompt")] diff --git a/Apps.AzueOpenAI/Actions/ImageActions.cs b/Apps.AzueOpenAI/Actions/ImageActions.cs index bd1b185..4282318 100644 --- a/Apps.AzueOpenAI/Actions/ImageActions.cs +++ b/Apps.AzueOpenAI/Actions/ImageActions.cs @@ -5,16 +5,14 @@ using Blackbird.Applications.Sdk.Common.Invocation; using Apps.AzureOpenAI.Models.Requests.Image; using Apps.AzureOpenAI.Models.Responses.Image; +using Blackbird.Applications.SDK.Extensions.FileManagement.Interfaces; namespace Apps.AzureOpenAI.Actions { [ActionList] - public class ImageActions : BaseActions + public class ImageActions(InvocationContext invocationContext, IFileManagementClient fileManagementClient) + : BaseActions(invocationContext, fileManagementClient) { - public ImageActions(InvocationContext invocationContext) : base(invocationContext) - { - } - [Action("Generate image", Description = "Generates an image based on a prompt")] public async Task GenerateImage([ActionParameter] ImageRequest input) { diff --git a/Apps.AzueOpenAI/Actions/TextAnalysisActions.cs b/Apps.AzueOpenAI/Actions/TextAnalysisActions.cs index 676d473..a9ad2b0 100644 --- a/Apps.AzueOpenAI/Actions/TextAnalysisActions.cs +++ b/Apps.AzueOpenAI/Actions/TextAnalysisActions.cs @@ -5,17 +5,15 @@ using Blackbird.Applications.Sdk.Common; using Blackbird.Applications.Sdk.Common.Actions; using Blackbird.Applications.Sdk.Common.Invocation; +using Blackbird.Applications.SDK.Extensions.FileManagement.Interfaces; using TiktokenSharp; namespace Apps.AzureOpenAI.Actions; [ActionList] -public class TextAnalysisActions : BaseActions +public class TextAnalysisActions(InvocationContext invocationContext, IFileManagementClient fileManagementClient) + : BaseActions(invocationContext, fileManagementClient) { - public TextAnalysisActions(InvocationContext invocationContext) : base(invocationContext) - { - } - [Action("Create embedding", Description = "Generate an embedding for a text provided. An embedding is a list of " + "floating point numbers that captures semantic information about the " + "text that it represents.")] diff --git a/Apps.AzueOpenAI/Actions/XliffActions.cs b/Apps.AzueOpenAI/Actions/XliffActions.cs index 1bf0aae..e802021 100644 --- a/Apps.AzueOpenAI/Actions/XliffActions.cs +++ b/Apps.AzueOpenAI/Actions/XliffActions.cs @@ -8,6 +8,7 @@ using System.Text; using Apps.AzureOpenAI.Models.Requests.Xliff; using Apps.AzureOpenAI.Actions.Base; +using Apps.AzureOpenAI.Constants; using Blackbird.Applications.Sdk.Common.Invocation; using Apps.AzureOpenAI.Models.Response.Xliff; using MoreLinq; @@ -15,22 +16,17 @@ using Blackbird.Xliff.Utils.Extensions; using Blackbird.Applications.Sdk.Glossaries.Utils.Converters; using Apps.AzureOpenAI.Models.Dto; +using Apps.AzureOpenAI.Models.Entities; using Apps.AzureOpenAI.Models.Requests.Chat; +using Apps.AzureOpenAI.Utils; using Azure.AI.OpenAI; -using Apps.AzureOpenAI.Utils.Xliff; namespace Apps.AzureOpenAI.Actions; [ActionList] -public class XliffActions : BaseActions +public class XliffActions(InvocationContext invocationContext, IFileManagementClient fileManagementClient) + : BaseActions(invocationContext, fileManagementClient) { - private readonly IFileManagementClient _fileManagementClient; - - public XliffActions(InvocationContext invocationContext, IFileManagementClient fileManagementClient) : base(invocationContext) - { - _fileManagementClient = fileManagementClient; - } - [Action("Process XLIFF file", Description = "Processes each translation unit in the XLIFF file according to the provided instructions (by default it just translates the source tags) and updates the target text for each unit. For now it supports only 1.2 version of XLIFF.")] @@ -49,23 +45,28 @@ public async Task TranslateXliff( "Specify the number of source texts to be translated at once. Default value: 1500. (See our documentation for an explanation)")] int? bucketSize = 1500) { - var fileStream = await _fileManagementClient.DownloadAsync(input.File); - var xliffDocument = Utils.Xliff.Extensions.ParseXLIFF(fileStream); + var xliffDocument = await DownloadXliffDocumentAsync(input.File); if (xliffDocument.TranslationUnits.Count == 0) { return new TranslateXliffResponse { File = input.File, Usage = new UsageDto() }; } - string systemPrompt = GetSystemPrompt(string.IsNullOrEmpty(prompt)); - var (translatedTexts, usage) = await GetTranslations(prompt, xliffDocument, systemPrompt, - bucketSize ?? 1500, - glossary.Glossary, promptRequest); - //var updatedResults = Utils.Xliff.Extensions.CheckTagIssues(xliffDocument.TranslationUnits, translatedTexts); - var stream = await _fileManagementClient.DownloadAsync(input.File); - var updatedFile = Blackbird.Xliff.Utils.Utils.XliffExtensions.UpdateOriginalFile(stream, translatedTexts); - string contentType = input.File.ContentType ?? "application/xml"; - var fileReference = await _fileManagementClient.UploadAsync(updatedFile, contentType, input.File.Name); - return new TranslateXliffResponse { File = fileReference, Usage = usage, Changes = translatedTexts.Count }; + var systemPrompt = GetSystemPrompt(string.IsNullOrEmpty(prompt)); + var (translatedTexts, usage) = await ProcessTranslationUnits(xliffDocument, + new(prompt, systemPrompt, bucketSize ?? 1500, promptRequest, glossary?.Glossary)); + + translatedTexts.ForEach(x => + { + var translationUnit = xliffDocument.TranslationUnits.FirstOrDefault(tu => tu.Id == x.TranslationId); + if (translationUnit != null) + { + translationUnit.Target = x.TranslatedText; + } + }); + + var fileReference = + await fileManagementClient.UploadAsync(xliffDocument.ToStream(), input.File.ContentType, input.File.Name); + return new TranslateXliffResponse { File = fileReference, Usage = usage }; } [Action("Get Quality Scores for XLIFF file", @@ -83,56 +84,54 @@ public async Task ScoreXLIFF( "Specify the number of translation units to be processed at once. Default value: 1500. (See our documentation for an explanation)")] int? bucketSize = 1500) { - var xliffDocument = await LoadAndParseXliffDocument(input.File); + var xliffDocument = await DownloadXliffDocumentAsync(input.File); string criteriaPrompt = string.IsNullOrEmpty(prompt) ? "accuracy, fluency, consistency, style, grammar and spelling" : prompt; - var results = new Dictionary(); + var batches = xliffDocument.TranslationUnits.Batch((int)bucketSize); var src = input.SourceLanguage ?? xliffDocument.SourceLanguage; var tgt = input.TargetLanguage ?? xliffDocument.TargetLanguage; - + var usage = new UsageDto(); - + var results = new Dictionary(); foreach (var batch in batches) { - string userPrompt = - $"Your input is going to be a group of sentences in {src} and their translation into {tgt}. " + - "Only provide as output the ID of the sentence and the score number as a comma separated array of tuples. " + - $"Place the tuples in a same line and separate them using semicolons, example for two assessments: 2,7;32,5. The score number is a score from 1 to 10 assessing the quality of the translation, considering the following criteria: {criteriaPrompt}. Sentences: "; - foreach (var tu in batch) - { - userPrompt += $" {tu.Id} {tu.Source} {tu.Target}"; - } - - var systemPrompt = - "You are a linguistic expert that should process the following texts accoring to the given instructions"; - var (result, promptUsage) = await ExecuteSystemPrompt(promptRequest, userPrompt, systemPrompt); + var userPrompt = PromptConstants.GetQualityScorePrompt(criteriaPrompt, src, tgt, + JsonConvert.SerializeObject(batch.Select(x => new { x.Id, x.Source, x.Target }).ToList())); + var (result, promptUsage) = await ExecuteOpenAIRequestAsync(new(userPrompt, PromptConstants.DefaultSystemPrompt, "2024-08-01-preview", + promptRequest, ResponseFormats.GetQualityScoreXliffResponseFormat())); usage += promptUsage; - - foreach (var r in result.Split(";")) + + TryCatchHelper.TryCatch(() => { - var split = r.Split(","); - results.Add(split[0], float.Parse(split[1])); - } - } - - var file = await _fileManagementClient.DownloadAsync(input.File); - string fileContent; - Encoding encoding; - using (var inFileStream = new StreamReader(file, true)) - { - encoding = inFileStream.CurrentEncoding; - fileContent = inFileStream.ReadToEnd(); + var deserializeResult = JsonConvert.DeserializeObject(result)!; + foreach (var entity in deserializeResult.Translations) + { + results.Add(entity.TranslationId, entity.QualityScore); + } + }, $"Failed to deserialize the response from OpenAI, try again later. Response: {result}"); } - - foreach (var r in results) + + results.ForEach(x => { - fileContent = Regex.Replace(fileContent, @"( tu.Id == x.Key); + if (translationUnit != null) + { + var attribute = translationUnit.Attributes.FirstOrDefault(x => x.Key == "extradata"); + if (!string.IsNullOrEmpty(attribute.Key)) + { + translationUnit.Attributes.Remove(attribute.Key); + translationUnit.Attributes.Add("extradata", x.Value.ToString()); + } + else + { + translationUnit.Attributes.Add("extradata", x.Value.ToString()); + } + } + }); - if (input is { Threshold: not null, Condition: not null, State: not null }) + if (input.Threshold != null && input.Condition != null && input.State != null) { var filteredTUs = new List(); switch (input.Condition) @@ -153,15 +152,31 @@ public async Task ScoreXLIFF( filteredTUs = results.Where(x => x.Value <= input.Threshold).Select(x => x.Key).ToList(); break; } - - fileContent = UpdateTargetState(fileContent, input.State, filteredTUs); + + filteredTUs.ForEach(x => + { + var translationUnit = xliffDocument.TranslationUnits.FirstOrDefault(tu => tu.Id == x); + if (translationUnit != null) + { + var stateAttribute = translationUnit.Attributes.FirstOrDefault(x => x.Key == "state"); + if (!string.IsNullOrEmpty(stateAttribute.Key)) + { + translationUnit.Attributes.Remove(stateAttribute.Key); + translationUnit.Attributes.Add("state", input.State); + } + else + { + translationUnit.Attributes.Add("state", input.State); + } + } + }); } + var stream = xliffDocument.ToStream(); return new ScoreXliffResponse { AverageScore = results.Average(x => x.Value), - File = await _fileManagementClient.UploadAsync(new MemoryStream(encoding.GetBytes(fileContent)), - MediaTypeNames.Text.Xml, input.File.Name), + File = await FileManagementClient.UploadAsync(stream, MediaTypeNames.Text.Xml, input.File.Name), Usage = usage, }; } @@ -176,75 +191,71 @@ public async Task PostEditXLIFF( string? prompt, [ActionParameter] GlossaryRequest glossary, [ActionParameter] BaseChatRequest promptRequest, - [ActionParameter, + [ActionParameter, Display("Bucket size", Description = "Specify the number of translation units to be processed at once. Default value: 1500. (See our documentation for an explanation)")] int? bucketSize = 1500) { - var fileStream = await _fileManagementClient.DownloadAsync(input.File); - var xliffDocument = Utils.Xliff.Extensions.ParseXLIFF(fileStream); + var xliffDocument = await DownloadXliffDocumentAsync(input.File); - var results = new Dictionary(); - var batches = xliffDocument.TranslationUnits.Batch((int)bucketSize); + var batches = xliffDocument.TranslationUnits.Batch((int)bucketSize!).ToList(); var src = input.SourceLanguage ?? xliffDocument.SourceLanguage; var tgt = input.TargetLanguage ?? xliffDocument.TargetLanguage; var usage = new UsageDto(); + var results = new List(); foreach (var batch in batches) { - string? glossaryPrompt = null; + var glossaryPrompt = string.Empty; if (glossary?.Glossary != null) { - var glossaryPromptPart = - await GetGlossaryPromptPart(glossary.Glossary, - string.Join(';', batch.Select(x => x.Source)) + ";" + - string.Join(';', batch.Select(x => x.Target))); - if (glossaryPromptPart != null) + var glossaryStream = await FileManagementClient.DownloadAsync(glossary.Glossary); + var blackbirdGlossary = await glossaryStream.ConvertFromTbx(); + glossaryPrompt = GlossaryPrompts.GetGlossaryPromptPart(blackbirdGlossary, + string.Join(';', batch.Select(x => x.Source))); + if (!string.IsNullOrEmpty(glossaryPrompt)) { glossaryPrompt += "Enhance the target text by incorporating relevant terms from our glossary where applicable. " + "Ensure that the translation aligns with the glossary entries for the respective languages. " + "If a term has variations or synonyms, consider them and choose the most appropriate " + "translation to maintain consistency and precision. "; - glossaryPrompt += glossaryPromptPart; } } - var userPrompt = - $"Your input consists of sentences in {src} language with their translations into {tgt}. " + - "Review and edit the translated target text as necessary to ensure it is a correct and accurate translation of the source text. " + - "If you see XML tags in the source also include them in the target text, don't delete or modify them. " + - "Include only the target texts (updated or not) in the format [ID:X]{target}. " + - $"Example: [ID:1]{{target1}},[ID:2]{{target2}}. " + - $"{prompt ?? ""} {glossaryPrompt ?? ""} Sentences: \n" + - string.Join("\n", batch.Select(tu => $"ID: {tu.Id}; Source: {tu.Source}; Target: {tu.Target}")); - - var systemPrompt = - "You are a linguistic expert that should process the following texts according to the given instructions"; - var (result, promptUsage) = await ExecuteSystemPrompt(promptRequest, userPrompt, systemPrompt); + var json = JsonConvert.SerializeObject(batch.Select(x => new { x.Id, x.Source, x.Target }).ToList()); + var userPrompt = PromptConstants.GetPostEditPrompt(prompt, glossaryPrompt, src, tgt, + json); + + var (result, promptUsage) = await ExecuteOpenAIRequestAsync(new(userPrompt, PromptConstants.DefaultSystemPrompt, + "2024-08-01-preview", promptRequest, ResponseFormats.GetProcessXliffResponseFormat())); usage += promptUsage; - var matches = Regex.Matches(result, @"\[ID:(.+?)\]\{([\s\S]+?)\}(?=,\[|$|,?\n)").Cast().ToList(); - foreach (var match in matches) + TryCatchHelper.TryCatch(() => { - if (match.Groups[2].Value.Contains("[ID:")) - continue; - results.Add(match.Groups[1].Value, match.Groups[2].Value); - } + var deserializedTranslations = JsonConvert.DeserializeObject(result)!; + results.AddRange(deserializedTranslations.Translations); + }, $"Failed to deserialize the response from OpenAI, try again later. Response: {result}"); } - var updatedResults = Utils.Xliff.Extensions.CheckTagIssues(xliffDocument.TranslationUnits, results); - var originalFile = await _fileManagementClient.DownloadAsync(input.File); - var updatedFile = Utils.Xliff.Extensions.UpdateOriginalFile(originalFile, updatedResults); + results.ForEach(x => + { + var translationUnit = xliffDocument.TranslationUnits.FirstOrDefault(tu => tu.Id == x.TranslationId); + if (translationUnit != null) + { + translationUnit.Target = x.TranslatedText; + } + }); - var finalFile = await _fileManagementClient.UploadAsync(updatedFile, input.File.ContentType, input.File.Name); - return new TranslateXliffResponse { File = finalFile, Usage = usage, }; + var fileReference = + await FileManagementClient.UploadAsync(xliffDocument.ToStream(), input.File.ContentType, input.File.Name); + return new TranslateXliffResponse { File = fileReference, Usage = usage }; } private async Task LoadAndParseXliffDocument(FileReference inputFile) { - var stream = await _fileManagementClient.DownloadAsync(inputFile); + var stream = await FileManagementClient.DownloadAsync(inputFile); var memoryStream = new MemoryStream(); await stream.CopyToAsync(memoryStream); memoryStream.Position = 0; @@ -254,7 +265,7 @@ private async Task LoadAndParseXliffDocument(FileReference inputF private async Task GetGlossaryPromptPart(FileReference glossary, string sourceContent) { - var glossaryStream = await _fileManagementClient.DownloadAsync(glossary); + var glossaryStream = await FileManagementClient.DownloadAsync(glossary); var blackbirdGlossary = await glossaryStream.ConvertFromTbx(); var glossaryPromptPart = new StringBuilder(); @@ -326,117 +337,41 @@ private string GetSystemPrompt(bool translator) return prompt; } - private async Task<(Dictionary, UsageDto)> GetTranslations(string prompt, ParsedXliff xliff, - string systemPrompt, int bucketSize, FileReference? glossary, - BaseChatRequest promptRequest) + private async Task<(List, UsageDto)> ProcessTranslationUnits(XliffDocument xliff, + XliffParameters parameters) { - - var results = new List(); - var batches = xliff.TranslationUnits.Batch(bucketSize); + var batches = xliff.TranslationUnits.Batch(parameters.BucketSize); var usageDto = new UsageDto(); + var entities = new List(); foreach (var batch in batches) { - string json = JsonConvert.SerializeObject(batch.Select(x => "{ID:" + x.Id + "}" + x.Source)); - - var userPrompt = GetUserPrompt(prompt + - "Reply with the processed text preserving the same format structure as provided, your output will need to be deserialized programmatically afterwards. Do not add linebreaks.", - xliff, json); + var json = JsonConvert.SerializeObject(batch.Select(x => new { x.Id, x.Source }).ToList()); + var prompt = PromptConstants.GetProcessPrompt(parameters.Prompt, xliff.SourceLanguage, + xliff.TargetLanguage, json); - if (glossary != null) + if (parameters.Glossary != null) { - var glossaryPromptPart = await GetGlossaryPromptPart(glossary, json); - if (glossaryPromptPart != null) - { - var glossaryPrompt = - "Enhance the target text by incorporating relevant terms from our glossary where applicable. " + - "Ensure that the translation aligns with the glossary entries for the respective languages. " + - "If a term has variations or synonyms, consider them and choose the most appropriate " + - "translation to maintain consistency and precision. "; - glossaryPrompt += glossaryPromptPart; - userPrompt += glossaryPrompt; - } + var glossaryStream = await FileManagementClient.DownloadAsync(parameters.Glossary); + var blackbirdGlossary = await glossaryStream.ConvertFromTbx(); + var glossaryPromptPart = GlossaryPrompts.GetGlossaryPromptPart(blackbirdGlossary, json); + prompt = GlossaryPrompts.GetGlossaryWithUserPrompt(prompt, glossaryPromptPart); } - var (response, promptUsage) = await ExecuteSystemPrompt(promptRequest, userPrompt, systemPrompt); - + var (response, promptUsage) = + await ExecuteOpenAIRequestAsync(new(prompt, parameters.SystemPrompt, "2024-08-01-preview", + parameters.ChatRequest, ResponseFormats.GetProcessXliffResponseFormat())); usageDto += promptUsage; + var translatedText = response.Trim(); - string filteredText = ""; - try + TryCatchHelper.TryCatch(() => { - filteredText = Regex.Match(translatedText, "\\[[\\s\\S]+\\]").Value; - if (String.IsNullOrEmpty(filteredText)) - { - var index = translatedText.LastIndexOf("\",") == -1 ? translatedText.LastIndexOf("\"\n,") : translatedText.LastIndexOf("\","); - index = index == -1 ? translatedText.LastIndexOf("\n\",") == -1? translatedText.LastIndexOf("\\n\",") : translatedText.LastIndexOf("\n\",") : index; - filteredText = translatedText.Remove(index) + "\"]"; - } - filteredText = Regex.Replace(filteredText, "\\n *", "").Replace("& ", "& "); - filteredText = Regex.Replace(filteredText, "\\\\n *", ""); - filteredText = Regex.Replace(filteredText,@"(\<(g|x) id=)\?\""(.*?)\?\"">", "${1}\"${3}\">"); - filteredText = Regex.Match(filteredText, "\\[[\\s\\S]+\\]").Value; - var result = JsonConvert.DeserializeObject(filteredText); - - results.AddRange(result); - } - catch (Exception e) - { - continue; - throw new Exception( - $"Failed to parse the translated text. Exception message: {e.Message}; Exception type: {e.GetType()}"); - } - + var deserializedTranslations = JsonConvert.DeserializeObject(translatedText)!; + entities.AddRange(deserializedTranslations.Translations); + }, $"Failed to deserialize the response from OpenAI, try again later. Response: {translatedText}"); } - return (results.Where(z => Regex.Match(z, "\\{ID:(.*?)\\}(.+)$").Groups[1].Value != "").ToDictionary(x => Regex.Match(x, "\\{ID:(.*?)\\}(.+)$").Groups[1].Value, y => Regex.Match(y, "\\{ID:(.*?)\\}(.+)$").Groups[2].Value.Trim()), usageDto); - } - string GetUserPrompt(string prompt, ParsedXliff xliffDocument, string json) - { - string instruction = string.IsNullOrEmpty(prompt) - ? $"Translate the following texts from {xliffDocument.SourceLanguage} to {xliffDocument.TargetLanguage}." - : $"Process the following texts as per the custom instructions: {prompt}. The source language is {xliffDocument.SourceLanguage} and the target language is {xliffDocument.TargetLanguage}. This information might be useful for the custom instructions."; - - return - $"Please provide a translation for each individual text, even if similar texts have been provided more than once. " + - $"{instruction} Return the outputs as a serialized JSON array of strings without additional formatting " + - $"(it is crucial because your response will be deserialized programmatically. Please ensure that your response is formatted correctly to avoid any deserialization issues). " + - $"Original texts (in serialized array format): {json}"; - } - - private XliffDocument UpdateXliffDocumentWithTranslations(XliffDocument xliffDocument, string[] translatedTexts, - bool updateLockedSegments) - { - var updatedUnits = xliffDocument.TranslationUnits.Zip(translatedTexts, (unit, translation) => - { - if (updateLockedSegments == false && unit.Attributes is not null && - unit.Attributes.Any(x => x.Key == "locked" && x.Value == "locked")) - { - unit.Target = unit.Target; - } - else - { - unit.Target = translation; - } - - return unit; - }).ToList(); - - var xDoc = xliffDocument.UpdateTranslationUnits(updatedUnits); - var stream = new MemoryStream(); - xDoc.Save(stream); - stream.Position = 0; - - return stream.ToXliffDocument(); - //new XliffConfig{ RemoveWhitespaces = true, CopyAttributes = true, IncludeInlineTags = true } - } - - private async Task UploadUpdatedDocument(XliffDocument xliffDocument, FileReference originalFile) - { - var outputMemoryStream = xliffDocument.ToStream(); //null, false, keepSingleAmpersands: true - - string contentType = originalFile.ContentType ?? "application/xml"; - return await _fileManagementClient.UploadAsync(outputMemoryStream, contentType, originalFile.Name); + return (entities, usageDto); } private async Task<(string result, UsageDto usage)> ExecuteSystemPrompt(BaseChatRequest input, @@ -444,10 +379,11 @@ private async Task UploadUpdatedDocument(XliffDocument xliffDocum string? systemPrompt = null) { var chatMessages = new List(); - if(systemPrompt != null) + if (systemPrompt != null) { chatMessages.Add(new ChatMessage(ChatRole.System, systemPrompt)); } + chatMessages.Add(new ChatMessage(ChatRole.User, prompt)); var response = await Client.GetChatCompletionsAsync( @@ -459,7 +395,8 @@ private async Task UploadUpdatedDocument(XliffDocument xliffDocum FrequencyPenalty = input.FrequencyPenalty, DeploymentName = DeploymentName, }); + var result = response.Value.Choices[0].Message.Content; return (result, new(response.Value.Usage)); } -} +} \ No newline at end of file diff --git a/Apps.AzueOpenAI/Apps.AzureOpenAI.csproj b/Apps.AzueOpenAI/Apps.AzureOpenAI.csproj index 85457af..5950b26 100644 --- a/Apps.AzueOpenAI/Apps.AzureOpenAI.csproj +++ b/Apps.AzueOpenAI/Apps.AzureOpenAI.csproj @@ -6,7 +6,7 @@ enable Azure OpenAI Azure OpenAI Service offers industry-leading coding and language AI models that you can fine-tune to your specific needs for a variety of use cases. - 1.1.9 + 1.1.12 Apps.AzureOpenAI @@ -15,8 +15,8 @@ - - + + @@ -27,4 +27,9 @@ + + + README.md + + \ No newline at end of file diff --git a/Apps.AzueOpenAI/Connections/ConnectionValidator.cs b/Apps.AzueOpenAI/Connections/ConnectionValidator.cs index 7d94770..9c5e2f8 100644 --- a/Apps.AzueOpenAI/Connections/ConnectionValidator.cs +++ b/Apps.AzueOpenAI/Connections/ConnectionValidator.cs @@ -10,7 +10,7 @@ public class ConnectionValidator : IConnectionValidator public async ValueTask ValidateConnection( IEnumerable authProviders, CancellationToken cancellationToken) { - var actions = new ChatActions(new InvocationContext() { AuthenticationCredentialsProviders = authProviders }); + var actions = new ChatActions(new InvocationContext() { AuthenticationCredentialsProviders = authProviders }, null!); try { //await actions.ChatMessageRequest(new ChatRequest() { Message = "hello" }); diff --git a/Apps.AzueOpenAI/Constants/CredNames.cs b/Apps.AzueOpenAI/Constants/CredNames.cs new file mode 100644 index 0000000..eabc8c3 --- /dev/null +++ b/Apps.AzueOpenAI/Constants/CredNames.cs @@ -0,0 +1,10 @@ +namespace Apps.AzureOpenAI.Constants; + +public static class CredNames +{ + public const string Url = "url"; + + public const string Deployment = "deployment"; + + public const string ApiKey = "apiKey"; +} \ No newline at end of file diff --git a/Apps.AzueOpenAI/Constants/GlossaryPrompts.cs b/Apps.AzueOpenAI/Constants/GlossaryPrompts.cs new file mode 100644 index 0000000..c88fab2 --- /dev/null +++ b/Apps.AzueOpenAI/Constants/GlossaryPrompts.cs @@ -0,0 +1,52 @@ +using System.Text; +using System.Text.RegularExpressions; +using Blackbird.Applications.Sdk.Glossaries.Utils.Dtos; + +namespace Apps.AzureOpenAI.Constants; + +public static class GlossaryPrompts +{ + public static string? GetGlossaryPromptPart(Glossary blackbirdGlossary, string sourceContentInJson) + { + var glossaryPromptPart = new StringBuilder(); + glossaryPromptPart.AppendLine(); + glossaryPromptPart.AppendLine(); + glossaryPromptPart.AppendLine("Glossary entries (each entry includes terms in different language. Each " + + "language may have a few synonymous variations which are separated by ;;):"); + + var entriesIncluded = false; + foreach (var entry in blackbirdGlossary.ConceptEntries) + { + var allTerms = entry.LanguageSections.SelectMany(x => x.Terms.Select(y => y.Term)); + if (!allTerms.Any(x => Regex.IsMatch(sourceContentInJson, $@"\b{x}\b", RegexOptions.IgnoreCase))) continue; + entriesIncluded = true; + + glossaryPromptPart.AppendLine(); + glossaryPromptPart.AppendLine("\tEntry:"); + + foreach (var section in entry.LanguageSections) + { + glossaryPromptPart.AppendLine( + $"\t\t{section.LanguageCode}: {string.Join(";; ", section.Terms.Select(term => term.Term))}"); + } + } + + return entriesIncluded ? glossaryPromptPart.ToString() : null; + } + + public static string GetGlossaryWithUserPrompt(string userPrompt, string? glossaryPromptPart) + { + if (glossaryPromptPart != null) + { + var glossaryPrompt = + "Enhance the target text by incorporating relevant terms from our glossary where applicable. " + + "Ensure that the translation aligns with the glossary entries for the respective languages. " + + "If a term has variations or synonyms, consider them and choose the most appropriate " + + "translation to maintain consistency and precision. "; + glossaryPrompt += glossaryPromptPart; + userPrompt += glossaryPrompt; + } + + return userPrompt; + } +} \ No newline at end of file diff --git a/Apps.AzueOpenAI/Constants/PromptConstants.cs b/Apps.AzueOpenAI/Constants/PromptConstants.cs new file mode 100644 index 0000000..3ad905f --- /dev/null +++ b/Apps.AzueOpenAI/Constants/PromptConstants.cs @@ -0,0 +1,77 @@ +namespace Apps.AzureOpenAI.Constants; + +public static class PromptConstants +{ + private const string TranslatePrompt = "$Translate the following texts from {source_language} to {target_language}"; + + private const string ProcessPrompt = + "Process the following texts as per the custom instructions: {prompt}. The source language is {source_language} and the target language is {target_language}. This information might be useful for the custom instructions."; + + private const string SummarizePrompt = + "Please provide a translation for each individual text, even if similar texts have been provided more than once. " + + "{instruction} Return the outputs as a serialized JSON array of strings without additional formatting " + + "(it is crucial because your response will be deserialized programmatically. Please ensure that your response is formatted correctly to avoid any deserialization issues). " + + "Original texts (in serialized array format): {json};\nReply with the processed text preserving the same format structure as provided, your output will need to be deserialized programmatically afterwards. Do not add linebreaks."; + + private const string PostEditPrompt = + "Your input consists of sentences in {source_language} language with their translations into {target_language}. " + + "Review and edit the translated target text as necessary to ensure it is a correct and accurate translation of the source text. " + + "If you see XML tags in the source also include them in the target text, don't delete or modify them. " + + "{prompt}; {glossary_prompt}. " + + "Translation units: {json}."; + + private const string QualityScorePrompt = + "Your input is going to be a group of sentences in {source_language} and their translation into {target_language}. " + + "Only provide as output the ID of the sentence and the score number as a comma separated array of tuples. " + + "Place the tuples in a same line and separate them using semicolons, example for two assessments: 2,7;32,5. The score number is a score from 1 to 10 assessing the quality of the translation, considering the following criteria: {criteria_prompt}. " + + "Sentences: {json}."; + + public const string DefaultSystemPrompt = + "You are a linguistic expert that should process the following texts according to the given instructions"; + + private static string GetTranslatePrompt(string sourceLanguage, string targetLanguage) + { + return TranslatePrompt.Replace("{source_language}", sourceLanguage) + .Replace("{target_language}", targetLanguage); + } + + private static string GetProcessPrompt(string prompt, string sourceLanguage, string targetLanguage) + { + return ProcessPrompt.Replace("{prompt}", prompt).Replace("{source_language}", sourceLanguage) + .Replace("{target_language}", targetLanguage); + } + + public static string GetProcessPrompt(string? userInstructions, string sourceLanguage, string targetLanguage, + string json) + { + var instructions = string.IsNullOrEmpty(userInstructions) + ? GetTranslatePrompt(sourceLanguage, targetLanguage) + : GetProcessPrompt(userInstructions, sourceLanguage, targetLanguage); + + return SummarizePrompt.Replace("{instruction}", instructions).Replace("{json}", json); + } + + public static string GetPostEditPrompt(string? prompt, string? glossaryPrompt, string sourceLanguage, + string targetLanguage, string json) + { + var result = prompt == null + ? PostEditPrompt.Replace("{prompt}; ", string.Empty) + : PostEditPrompt.Replace("{prompt}", prompt); + + result = glossaryPrompt == null + ? result.Replace("{glossary_prompt}. ", string.Empty) + : result.Replace("{glossary_prompt}", glossaryPrompt); + + return result.Replace("{source_language}", sourceLanguage) + .Replace("{target_language}", targetLanguage) + .Replace("{json}", json); + } + + public static string GetQualityScorePrompt(string criteriaPrompt, string sourceLanguage, string targetLanguage, string json) + { + return QualityScorePrompt.Replace("{criteria_prompt}", criteriaPrompt) + .Replace("{source_language}", sourceLanguage) + .Replace("{target_language}", targetLanguage) + .Replace("{json}", json); + } +} \ No newline at end of file diff --git a/Apps.AzueOpenAI/Constants/ResponseFormats.cs b/Apps.AzueOpenAI/Constants/ResponseFormats.cs new file mode 100644 index 0000000..50b14ad --- /dev/null +++ b/Apps.AzueOpenAI/Constants/ResponseFormats.cs @@ -0,0 +1,104 @@ +namespace Apps.AzureOpenAI.Constants; + +public static class ResponseFormats +{ + public static object GetProcessXliffResponseFormat() + { + return new + { + type = "json_schema", + json_schema = new + { + name = "TranslatedTexts", + strict = true, + schema = new + { + type = "object", + properties = new + { + translations = new + { + type = "array", + items = new + { + type = "object", + properties = new + { + translation_id = new + { + type = "string" + }, + translated_text = new + { + type = "string" + } + }, + required = new[] + { + "translation_id", + "translated_text" + }, + additionalProperties = false + } + } + }, + required = new[] + { + "translations" + }, + additionalProperties = false + } + } + }; + } + + public static object GetQualityScoreXliffResponseFormat() + { + return new + { + type = "json_schema", + json_schema = new + { + name = "TranslatedTexts", + strict = true, + schema = new + { + type = "object", + properties = new + { + translations = new + { + type = "array", + items = new + { + type = "object", + properties = new + { + translation_id = new + { + type = "string" + }, + quality_score = new + { + type = "number" + } + }, + required = new[] + { + "translation_id", + "quality_score" + }, + additionalProperties = false + } + } + }, + required = new[] + { + "translations" + }, + additionalProperties = false + } + } + }; + } +} \ No newline at end of file diff --git a/Apps.AzueOpenAI/Models/Dto/ErrorDto.cs b/Apps.AzueOpenAI/Models/Dto/ErrorDto.cs new file mode 100644 index 0000000..1bb3f8d --- /dev/null +++ b/Apps.AzueOpenAI/Models/Dto/ErrorDto.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; + +namespace Apps.AzureOpenAI.Models.Dto; + +public class ErrorDto +{ + [JsonProperty("error")] + public Error Error { get; set; } = new(); + + public override string ToString() + { + return $"We encountered an error. Error code: {Error.Code}; Error message: {Error.Message}"; + } +} + +public class Error +{ + [JsonProperty("code")] + public string Code { get; set; } = string.Empty; + + [JsonProperty("message")] + public string Message { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/Apps.AzueOpenAI/Models/Dto/OpenAIResponseDto.cs b/Apps.AzueOpenAI/Models/Dto/OpenAIResponseDto.cs new file mode 100644 index 0000000..1310983 --- /dev/null +++ b/Apps.AzueOpenAI/Models/Dto/OpenAIResponseDto.cs @@ -0,0 +1,57 @@ +using Newtonsoft.Json; + +namespace Apps.AzureOpenAI.Models.Dto; + +public class OpenAIResponseDto +{ + [JsonProperty("id")] + public string Id { get; set; } = string.Empty; + + [JsonProperty("created")] + public long Created { get; set; } + + [JsonProperty("model")] + public string Model { get; set; } = string.Empty; + + [JsonProperty("object")] + public string Object { get; set; } = string.Empty; + + [JsonProperty("choices")] + public List Choices { get; set; } = new(); + + [JsonProperty("usage")] + public CompletionsUsage Usage { get; set; } = new(); +} + +public class ChatChoice +{ + [JsonProperty("finish_reason")] + public string FinishReason { get; set; } = string.Empty; + + [JsonProperty("index")] + public int Index { get; set; } + + [JsonProperty("message")] + public Message Message { get; set; } = new(); +} + +public class Message +{ + [JsonProperty("content"),] + public string Content { get; set; } = string.Empty; + + [JsonProperty("role")] + public string Role { get; set; } = string.Empty; +} + +public class CompletionsUsage +{ + [JsonProperty("completion_tokens")] + public int CompletionTokens { get; set; } + + [JsonProperty("prompt_tokens")] + public int PromptTokens { get; set; } + + [JsonProperty("total_tokens")] + public int TotalTokens { get; set; } +} \ No newline at end of file diff --git a/Apps.AzueOpenAI/Models/Dto/UsageDto.cs b/Apps.AzueOpenAI/Models/Dto/UsageDto.cs index 6a361e2..b1ca1aa 100644 --- a/Apps.AzueOpenAI/Models/Dto/UsageDto.cs +++ b/Apps.AzueOpenAI/Models/Dto/UsageDto.cs @@ -1,5 +1,4 @@ -using Azure.AI.OpenAI; -using Blackbird.Applications.Sdk.Common; +using Blackbird.Applications.Sdk.Common; namespace Apps.AzureOpenAI.Models.Dto; @@ -10,8 +9,7 @@ public class UsageDto [Display("Completion tokens")] public int CompletionTokens { get; set; } [Display("Total tokens")] public int TotalTokens { get; set; } - - + public static UsageDto operator +(UsageDto u1, UsageDto u2) { return new UsageDto @@ -32,4 +30,11 @@ public UsageDto(CompletionsUsage usageMetadata) TotalTokens = usageMetadata.TotalTokens; CompletionTokens = usageMetadata.CompletionTokens; } + + public UsageDto(Azure.AI.OpenAI.CompletionsUsage usageMetadata) + { + PromptTokens = usageMetadata.PromptTokens; + TotalTokens = usageMetadata.TotalTokens; + CompletionTokens = usageMetadata.CompletionTokens; + } } diff --git a/Apps.AzueOpenAI/Models/Entities/ExecuteOpenAIRequestParameters.cs b/Apps.AzueOpenAI/Models/Entities/ExecuteOpenAIRequestParameters.cs new file mode 100644 index 0000000..6bbb81c --- /dev/null +++ b/Apps.AzueOpenAI/Models/Entities/ExecuteOpenAIRequestParameters.cs @@ -0,0 +1,5 @@ +using Apps.AzureOpenAI.Models.Requests.Chat; + +namespace Apps.AzureOpenAI.Models.Entities; + +public record ExecuteOpenAIRequestParameters(string Prompt, string SystemPrompt, string ApiVersion, BaseChatRequest ChatRequest, object? ResponseFormat); \ No newline at end of file diff --git a/Apps.AzueOpenAI/Models/Entities/TranslationEntities.cs b/Apps.AzueOpenAI/Models/Entities/TranslationEntities.cs new file mode 100644 index 0000000..799d5bc --- /dev/null +++ b/Apps.AzueOpenAI/Models/Entities/TranslationEntities.cs @@ -0,0 +1,9 @@ +using Newtonsoft.Json; + +namespace Apps.AzureOpenAI.Models.Entities; + +public class TranslationEntities +{ + [JsonProperty("translations")] + public List Translations { get; set; } = new(); +} \ No newline at end of file diff --git a/Apps.AzueOpenAI/Models/Entities/TranslationEntity.cs b/Apps.AzueOpenAI/Models/Entities/TranslationEntity.cs new file mode 100644 index 0000000..0902f73 --- /dev/null +++ b/Apps.AzueOpenAI/Models/Entities/TranslationEntity.cs @@ -0,0 +1,15 @@ +using Newtonsoft.Json; + +namespace Apps.AzureOpenAI.Models.Entities; + +public class TranslationEntity +{ + [JsonProperty("translation_id")] + public string TranslationId { get; set; } = string.Empty; + + [JsonProperty("translated_text")] + public string TranslatedText { get; set; } = string.Empty; + + [JsonProperty("quality_score")] + public float QualityScore { get; set; } +} \ No newline at end of file diff --git a/Apps.AzueOpenAI/Models/Entities/XliffParameters.cs b/Apps.AzueOpenAI/Models/Entities/XliffParameters.cs new file mode 100644 index 0000000..19a210d --- /dev/null +++ b/Apps.AzueOpenAI/Models/Entities/XliffParameters.cs @@ -0,0 +1,6 @@ +using Apps.AzureOpenAI.Models.Requests.Chat; +using Blackbird.Applications.Sdk.Common.Files; + +namespace Apps.AzureOpenAI.Models.Entities; + +public record XliffParameters(string? Prompt, string SystemPrompt, int BucketSize, BaseChatRequest ChatRequest, FileReference? Glossary); \ No newline at end of file diff --git a/Apps.AzueOpenAI/Utils/TryCatchHelper.cs b/Apps.AzueOpenAI/Utils/TryCatchHelper.cs new file mode 100644 index 0000000..6f780a5 --- /dev/null +++ b/Apps.AzueOpenAI/Utils/TryCatchHelper.cs @@ -0,0 +1,16 @@ +namespace Apps.AzureOpenAI.Utils; + +public static class TryCatchHelper +{ + public static void TryCatch(Action action, string message) + { + try + { + action(); + } + catch (Exception ex) + { + throw new Exception($"Exception message: {ex.Message}. {message}"); + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index f2b22a9..d1ae9d5 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,9 @@ You can find how to create and deploy an Azure OpenAI Service resource [here](ht - **Tokenize text**: Tokenize the text provided. Optionally specify encoding: cl100k_base (used by gpt-4, gpt-3.5-turbo, text-embedding-ada-002) or p50k_base (used by codex models, text-davinci-002, text-davinci-003). ### XLIFF Actions + +Note, currently only gpt-4o version: 2024-08-06 supports structured outputs. This means that the actions that support XLIFF files can only be used with this model version. You can find the relevant information about supported models in the [Azure OpenAI documentation](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/structured-outputs?tabs=rest). + - **Get Quality Scores for XLIFF file** Gets segment and file level quality scores for XLIFF files. Supports only version 1.2 of XLIFF currently. Optionally, you can add Threshold, New Target State and Condition input parameters to the Blackbird action to change the target state value of segments meeting the desired criteria (all three must be filled). Optional inputs: