diff --git a/FictionArchive.Service.TranslationService.Tests/FictionArchive.Service.TranslationService.Tests.csproj b/FictionArchive.Service.TranslationService.Tests/FictionArchive.Service.TranslationService.Tests.csproj new file mode 100644 index 0000000..ff46c93 --- /dev/null +++ b/FictionArchive.Service.TranslationService.Tests/FictionArchive.Service.TranslationService.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/FictionArchive.Service.TranslationService.Tests/NanoGpt/NanoGptTranslationEngineTests.cs b/FictionArchive.Service.TranslationService.Tests/NanoGpt/NanoGptTranslationEngineTests.cs new file mode 100644 index 0000000..2f039ae --- /dev/null +++ b/FictionArchive.Service.TranslationService.Tests/NanoGpt/NanoGptTranslationEngineTests.cs @@ -0,0 +1,187 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using FictionArchive.Service.TranslationService.Models.Enums; +using FictionArchive.Service.TranslationService.Services.TranslationEngines.NanoGpt; +using FictionArchive.Service.TranslationService.Tests.TestSupport; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Polly; +using Xunit; +using Language = FictionArchive.Common.Enums.Language; + +namespace FictionArchive.Service.TranslationService.Tests.NanoGpt; + +public class NanoGptTranslationEngineTests +{ + private static (NanoGptTranslationEngine engine, TestHttpMessageHandler handler) + BuildEngine(Func responder, string modelKey = "nanogpt-test", string modelId = "test-model") + { + var handler = new TestHttpMessageHandler(responder); + var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://nano-gpt.test/api/") }; + var apiClient = new NanoGptApiClient(httpClient, NullLogger.Instance); + var options = new NanoGptModelOptions { Key = modelKey, DisplayName = "Test", Model = modelId }; + var pipeline = new ResiliencePipelineBuilder().Build(); // no-retry pipeline for deterministic tests + var engine = new NanoGptTranslationEngine(apiClient, options, pipeline, NullLogger.Instance); + return (engine, handler); + } + + private static HttpResponseMessage JsonResponse(HttpStatusCode status, string json) + { + return new HttpResponseMessage(status) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + } + + private const string SuccessJson = """ + { + "choices": [{"message": {"role":"assistant","content":"안녕, 세상."}}], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15} + } + """; + + [Fact] + public void Descriptor_ReflectsConfiguredOptions() + { + var (engine, _) = BuildEngine(_ => JsonResponse(HttpStatusCode.OK, SuccessJson), + modelKey: "nanogpt-gpt-4o"); + + engine.Descriptor.Key.Should().Be("nanogpt-gpt-4o"); + engine.Descriptor.DisplayName.Should().Be("Test"); + } + + [Fact] + public async Task GetTranslation_OnSuccess_ReturnsTranslatedTextAndUsage() + { + var (engine, _) = BuildEngine(_ => JsonResponse(HttpStatusCode.OK, SuccessJson)); + + var result = await engine.GetTranslation("Hello, world.", Language.En, Language.Kr); + + result.Status.Should().Be(TranslationRequestStatus.Success); + result.TranslatedText.Should().Be("안녕, 세상."); + result.OriginalText.Should().Be("Hello, world."); + result.From.Should().Be(Language.En); + result.To.Should().Be(Language.Kr); + result.TranslationEngineKey.Should().Be("nanogpt-test"); + result.BilledCharacterCount.Should().Be(15u); + } + + [Fact] + public async Task GetTranslation_PostsToChatCompletionsEndpoint() + { + var (engine, handler) = BuildEngine(_ => JsonResponse(HttpStatusCode.OK, SuccessJson)); + + await engine.GetTranslation("Hello", Language.En, Language.Kr); + + handler.Requests.Should().HaveCount(1); + var request = handler.Requests[0]; + request.Method.Should().Be(HttpMethod.Post); + request.RequestUri!.AbsoluteUri.Should().Be("https://nano-gpt.test/api/v1/chat/completions"); + } + + [Fact] + public async Task GetTranslation_SendsConfiguredModelAndTemperature() + { + var (engine, handler) = BuildEngine( + _ => JsonResponse(HttpStatusCode.OK, SuccessJson), + modelId: "chatgpt-4o-latest"); + + await engine.GetTranslation("Hello", Language.En, Language.Kr); + + var body = JsonDocument.Parse(handler.RequestBodies[0]).RootElement; + body.GetProperty("model").GetString().Should().Be("chatgpt-4o-latest"); + body.GetProperty("temperature").GetDouble().Should().Be(0.2); + } + + [Fact] + public async Task GetTranslation_WrapsSourceTextInDelimitersAndIncludesSystemPrompt() + { + var (engine, handler) = BuildEngine(_ => JsonResponse(HttpStatusCode.OK, SuccessJson)); + + await engine.GetTranslation("Hello", Language.En, Language.Kr); + + var body = JsonDocument.Parse(handler.RequestBodies[0]).RootElement; + var messages = body.GetProperty("messages"); + messages.GetArrayLength().Should().Be(2); + + var system = messages[0]; + system.GetProperty("role").GetString().Should().Be("system"); + var systemContent = system.GetProperty("content").GetString()!; + systemContent.Should().Contain("English").And.Contain("Korean"); + systemContent.Should().Contain("").And.Contain("Output only the translated text"); + + var user = messages[1]; + user.GetProperty("role").GetString().Should().Be("user"); + user.GetProperty("content").GetString().Should().Be("\nHello\n"); + } + + [Theory] + [InlineData(Language.En, "English")] + [InlineData(Language.Kr, "Korean")] + [InlineData(Language.Ch, "Chinese")] + [InlineData(Language.Ja, "Japanese")] + public async Task GetTranslation_RendersExpectedLanguageNamesInSystemPrompt(Language to, string expectedName) + { + var (engine, handler) = BuildEngine(_ => JsonResponse(HttpStatusCode.OK, SuccessJson)); + + await engine.GetTranslation("Hello", Language.En, to); + + var body = JsonDocument.Parse(handler.RequestBodies[0]).RootElement; + var systemContent = body.GetProperty("messages")[0].GetProperty("content").GetString()!; + systemContent.Should().Contain(expectedName); + } + + [Fact] + public async Task GetTranslation_On4xx_ReturnsFailedResultWithoutThrowing() + { + var (engine, _) = BuildEngine(_ => JsonResponse(HttpStatusCode.BadRequest, + """{"error":"bad model"}""")); + + var result = await engine.GetTranslation("Hello", Language.En, Language.Kr); + + result.Status.Should().Be(TranslationRequestStatus.Failed); + result.TranslatedText.Should().BeNull(); + result.TranslationEngineKey.Should().Be("nanogpt-test"); + result.OriginalText.Should().Be("Hello"); + } + + [Fact] + public async Task GetTranslation_On5xx_ReturnsFailedResultWithoutThrowing() + { + var (engine, _) = BuildEngine(_ => JsonResponse(HttpStatusCode.InternalServerError, "{}")); + + var result = await engine.GetTranslation("Hello", Language.En, Language.Kr); + + result.Status.Should().Be(TranslationRequestStatus.Failed); + result.TranslatedText.Should().BeNull(); + } + + [Fact] + public async Task GetTranslation_OnMalformedJson_ReturnsFailedResult() + { + var (engine, _) = BuildEngine(_ => JsonResponse(HttpStatusCode.OK, "not json")); + + var result = await engine.GetTranslation("Hello", Language.En, Language.Kr); + + result.Status.Should().Be(TranslationRequestStatus.Failed); + result.TranslatedText.Should().BeNull(); + } + + [Fact] + public async Task GetTranslation_OnEmptyChoices_ReturnsFailedResult() + { + const string emptyChoicesJson = """ + { + "choices": [], + "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} + } + """; + var (engine, _) = BuildEngine(_ => JsonResponse(HttpStatusCode.OK, emptyChoicesJson)); + + var result = await engine.GetTranslation("Hello", Language.En, Language.Kr); + + result.Status.Should().Be(TranslationRequestStatus.Failed); + result.TranslatedText.Should().BeNull(); + } +} diff --git a/FictionArchive.Service.TranslationService.Tests/Resilience/TranslationResiliencePipelineTests.cs b/FictionArchive.Service.TranslationService.Tests/Resilience/TranslationResiliencePipelineTests.cs new file mode 100644 index 0000000..a7cbe3d --- /dev/null +++ b/FictionArchive.Service.TranslationService.Tests/Resilience/TranslationResiliencePipelineTests.cs @@ -0,0 +1,127 @@ +using DeepL; +using FictionArchive.Service.TranslationService.Services.Resilience; +using FluentAssertions; +using Polly; +using Xunit; + +namespace FictionArchive.Service.TranslationService.Tests.Resilience; + +public class TranslationResiliencePipelineTests +{ + [Fact] + public async Task RetriesTransientHttpRequestException_ThenSucceeds() + { + var pipeline = TranslationResiliencePipeline.Build(); + var attempts = 0; + + var result = await pipeline.ExecuteAsync(async _ => + { + attempts++; + if (attempts < 3) + { + throw new HttpRequestException("transient"); + } + return await ValueTask.FromResult("ok"); + }); + + attempts.Should().Be(3); + result.Should().Be("ok"); + } + + [Fact] + public async Task RetriesDeepLConnectionException() + { + var pipeline = TranslationResiliencePipeline.Build(); + var attempts = 0; + + var result = await pipeline.ExecuteAsync(async _ => + { + attempts++; + if (attempts < 2) + { + throw new ConnectionException("transient", new HttpRequestException("inner")); + } + return await ValueTask.FromResult("ok"); + }); + + attempts.Should().Be(2); + result.Should().Be("ok"); + } + + [Fact] + public async Task RetriesDeepLTooManyRequestsException() + { + var pipeline = TranslationResiliencePipeline.Build(); + var attempts = 0; + + var result = await pipeline.ExecuteAsync(async _ => + { + attempts++; + if (attempts < 2) + { + throw new TooManyRequestsException("rate limited"); + } + return await ValueTask.FromResult("ok"); + }); + + attempts.Should().Be(2); + result.Should().Be("ok"); + } + + [Fact] + public async Task DoesNotRetryNonTransientException() + { + var pipeline = TranslationResiliencePipeline.Build(); + var attempts = 0; + + var act = async () => await pipeline.ExecuteAsync(async _ => + { + attempts++; + throw new ArgumentException("permanent"); +#pragma warning disable CS0162 + return await ValueTask.FromResult("never"); +#pragma warning restore CS0162 + }); + + await act.Should().ThrowAsync(); + attempts.Should().Be(1); + } + + [Fact] + public async Task DoesNotRetryDeepLAuthorizationException() + { + var pipeline = TranslationResiliencePipeline.Build(); + var attempts = 0; + + var act = async () => await pipeline.ExecuteAsync(async _ => + { + attempts++; + throw new AuthorizationException("bad key"); +#pragma warning disable CS0162 + return await ValueTask.FromResult("never"); +#pragma warning restore CS0162 + }); + + await act.Should().ThrowAsync(); + attempts.Should().Be(1); + } + + [Fact] + public async Task ExhaustsRetryBudget_ThenRethrows() + { + var pipeline = TranslationResiliencePipeline.Build(); + var attempts = 0; + + var act = async () => await pipeline.ExecuteAsync(async _ => + { + attempts++; + throw new HttpRequestException("always transient"); +#pragma warning disable CS0162 + return await ValueTask.FromResult("never"); +#pragma warning restore CS0162 + }); + + await act.Should().ThrowAsync(); + attempts.Should().Be(4); // initial attempt + 3 retries + } +} diff --git a/FictionArchive.Service.TranslationService.Tests/TestSupport/TestHttpMessageHandler.cs b/FictionArchive.Service.TranslationService.Tests/TestSupport/TestHttpMessageHandler.cs new file mode 100644 index 0000000..d78eb90 --- /dev/null +++ b/FictionArchive.Service.TranslationService.Tests/TestSupport/TestHttpMessageHandler.cs @@ -0,0 +1,25 @@ +namespace FictionArchive.Service.TranslationService.Tests.TestSupport; + +public sealed class TestHttpMessageHandler : HttpMessageHandler +{ + private readonly Func _responder; + + public List Requests { get; } = new(); + public List RequestBodies { get; } = new(); + + public TestHttpMessageHandler(Func responder) + { + _responder = responder; + } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var bodyText = request.Content is null + ? string.Empty + : await request.Content.ReadAsStringAsync(cancellationToken); + Requests.Add(request); + RequestBodies.Add(bodyText); + return _responder(request); + } +} diff --git a/FictionArchive.Service.TranslationService/FictionArchive.Service.TranslationService.csproj b/FictionArchive.Service.TranslationService/FictionArchive.Service.TranslationService.csproj index cd4d3c3..6a30490 100644 --- a/FictionArchive.Service.TranslationService/FictionArchive.Service.TranslationService.csproj +++ b/FictionArchive.Service.TranslationService/FictionArchive.Service.TranslationService.csproj @@ -22,6 +22,7 @@ + diff --git a/FictionArchive.Service.TranslationService/Program.cs b/FictionArchive.Service.TranslationService/Program.cs index bcd9fd1..3aaefac 100644 --- a/FictionArchive.Service.TranslationService/Program.cs +++ b/FictionArchive.Service.TranslationService/Program.cs @@ -9,6 +9,10 @@ using FictionArchive.Service.TranslationService.Services; using FictionArchive.Service.TranslationService.Services.Database; using FictionArchive.Service.TranslationService.Services.TranslationEngines; using FictionArchive.Service.TranslationService.Services.TranslationEngines.DeepLTranslate; +using System.Net.Http.Headers; +using FictionArchive.Service.TranslationService.Services.Resilience; +using FictionArchive.Service.TranslationService.Services.TranslationEngines.NanoGpt; +using Polly; namespace FictionArchive.Service.TranslationService; @@ -55,12 +59,44 @@ public class Program #region Translation Adapter - builder.Services.AddTransient(provider => - { - return new DeepLClient(builder.Configuration["DeepL:ApiKey"]); - }); + // Shared resilience pipeline used by both DeepL and NanoGPT engines. + builder.Services.AddSingleton(TranslationResiliencePipeline.Build()); + + // DeepL + builder.Services.AddTransient(_ => new DeepLClient(builder.Configuration["DeepL:ApiKey"])); builder.Services.AddTransient(); + // NanoGPT — typed HttpClient (bearer auth + base address + 120s timeout). + builder.Services.AddHttpClient(client => + { + var baseAddress = builder.Configuration["NanoGpt:BaseAddress"]; + if (!string.IsNullOrWhiteSpace(baseAddress)) + { + client.BaseAddress = new Uri(baseAddress); + } + + var apiKey = builder.Configuration["NanoGpt:ApiKey"]; + if (!string.IsNullOrWhiteSpace(apiKey) && apiKey != "REPLACE_ME") + { + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + } + + client.Timeout = TimeSpan.FromSeconds(120); + }); + + // Register one ITranslationEngine per configured NanoGPT model. + var nanoGptModels = builder.Configuration + .GetSection("NanoGpt:Models") + .Get() ?? []; + foreach (var modelOptions in nanoGptModels) + { + builder.Services.AddTransient(sp => new NanoGptTranslationEngine( + sp.GetRequiredService(), + modelOptions, + sp.GetRequiredService(), + sp.GetRequiredService>())); + } + builder.Services.AddTransient(); #endregion diff --git a/FictionArchive.Service.TranslationService/Services/Resilience/TranslationResiliencePipeline.cs b/FictionArchive.Service.TranslationService/Services/Resilience/TranslationResiliencePipeline.cs new file mode 100644 index 0000000..dfc27b7 --- /dev/null +++ b/FictionArchive.Service.TranslationService/Services/Resilience/TranslationResiliencePipeline.cs @@ -0,0 +1,26 @@ +using DeepL; +using Polly; +using Polly.Retry; + +namespace FictionArchive.Service.TranslationService.Services.Resilience; + +public static class TranslationResiliencePipeline +{ + public static ResiliencePipeline Build() + { + return new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + ShouldHandle = new PredicateBuilder() + .Handle() + .Handle(ex => ex.InnerException is TimeoutException) + .Handle() + .Handle(), + MaxRetryAttempts = 3, + BackoffType = DelayBackoffType.Exponential, + Delay = TimeSpan.FromMilliseconds(500), + UseJitter = true + }) + .Build(); + } +} diff --git a/FictionArchive.Service.TranslationService/Services/TranslationEngines/DeepLTranslate/DeepLTranslationEngine.cs b/FictionArchive.Service.TranslationService/Services/TranslationEngines/DeepLTranslate/DeepLTranslationEngine.cs index c119a08..377e579 100644 --- a/FictionArchive.Service.TranslationService/Services/TranslationEngines/DeepLTranslate/DeepLTranslationEngine.cs +++ b/FictionArchive.Service.TranslationService/Services/TranslationEngines/DeepLTranslate/DeepLTranslationEngine.cs @@ -2,6 +2,7 @@ using DeepL; using DeepL.Model; using FictionArchive.Service.TranslationService.Models; using FictionArchive.Service.TranslationService.Models.Enums; +using Polly; using Language = FictionArchive.Common.Enums.Language; namespace FictionArchive.Service.TranslationService.Services.TranslationEngines.DeepLTranslate; @@ -9,34 +10,38 @@ namespace FictionArchive.Service.TranslationService.Services.TranslationEngines. public class DeepLTranslationEngine : ITranslationEngine { private readonly DeepLClient _deepLClient; + private readonly ResiliencePipeline _pipeline; private readonly ILogger _logger; private const string DisplayName = "DeepL"; private const string Key = "deepl"; - public DeepLTranslationEngine(DeepLClient deepLClient, ILogger logger) + public DeepLTranslationEngine( + DeepLClient deepLClient, + ResiliencePipeline pipeline, + ILogger logger) { _deepLClient = deepLClient; + _pipeline = pipeline; _logger = logger; } - public TranslationEngineDescriptor Descriptor + public TranslationEngineDescriptor Descriptor => new() { - get - { - return new TranslationEngineDescriptor() - { - DisplayName = DisplayName, - Key = Key, - }; - } - } + DisplayName = DisplayName, + Key = Key, + }; public async Task GetTranslation(string body, Language from, Language to) { - TextResult translationResult = await _deepLClient.TranslateTextAsync(body, GetLanguageCode(from), GetLanguageCode(to)); - _logger.LogInformation("Translated text. Usage statistics: CHARACTERS BILLED {TranslationResultBilledCharacters}", translationResult.BilledCharacters); - return new TranslationResult() + TextResult translationResult = await _pipeline.ExecuteAsync( + async _ => await _deepLClient.TranslateTextAsync(body, GetLanguageCode(from), GetLanguageCode(to))); + + _logger.LogInformation( + "Translated text. Usage statistics: CHARACTERS BILLED {TranslationResultBilledCharacters}", + translationResult.BilledCharacters); + + return new TranslationResult { OriginalText = body, From = from, @@ -59,4 +64,4 @@ public class DeepLTranslationEngine : ITranslationEngine _ => throw new ArgumentOutOfRangeException(nameof(language), language, null) }; } -} \ No newline at end of file +} diff --git a/FictionArchive.Service.TranslationService/Services/TranslationEngines/NanoGpt/NanoGptApiClient.cs b/FictionArchive.Service.TranslationService/Services/TranslationEngines/NanoGpt/NanoGptApiClient.cs new file mode 100644 index 0000000..e85881e --- /dev/null +++ b/FictionArchive.Service.TranslationService/Services/TranslationEngines/NanoGpt/NanoGptApiClient.cs @@ -0,0 +1,34 @@ +using System.Net.Http.Json; + +namespace FictionArchive.Service.TranslationService.Services.TranslationEngines.NanoGpt; + +public class NanoGptApiClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public NanoGptApiClient(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public async Task CompleteAsync( + string model, + IReadOnlyList messages, + double temperature, + CancellationToken ct = default) + { + var request = new NanoGptChatRequest(model, messages, temperature); + + using var response = await _httpClient.PostAsJsonAsync("v1/chat/completions", request, ct); + response.EnsureSuccessStatusCode(); + + var body = await response.Content.ReadFromJsonAsync(ct); + if (body is null) + { + throw new InvalidOperationException("NanoGPT returned an empty response body."); + } + return body; + } +} diff --git a/FictionArchive.Service.TranslationService/Services/TranslationEngines/NanoGpt/NanoGptDtos.cs b/FictionArchive.Service.TranslationService/Services/TranslationEngines/NanoGpt/NanoGptDtos.cs new file mode 100644 index 0000000..7b7baf6 --- /dev/null +++ b/FictionArchive.Service.TranslationService/Services/TranslationEngines/NanoGpt/NanoGptDtos.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace FictionArchive.Service.TranslationService.Services.TranslationEngines.NanoGpt; + +public sealed record NanoGptMessage( + [property: JsonPropertyName("role")] string Role, + [property: JsonPropertyName("content")] string Content); + +public sealed record NanoGptChatRequest( + [property: JsonPropertyName("model")] string Model, + [property: JsonPropertyName("messages")] IReadOnlyList Messages, + [property: JsonPropertyName("temperature")] double Temperature); + +public sealed record NanoGptChatResponse( + [property: JsonPropertyName("choices")] IReadOnlyList Choices, + [property: JsonPropertyName("usage")] NanoGptUsage Usage); + +public sealed record NanoGptChoice( + [property: JsonPropertyName("message")] NanoGptMessage Message); + +public sealed record NanoGptUsage( + [property: JsonPropertyName("prompt_tokens")] int PromptTokens, + [property: JsonPropertyName("completion_tokens")] int CompletionTokens, + [property: JsonPropertyName("total_tokens")] int TotalTokens); diff --git a/FictionArchive.Service.TranslationService/Services/TranslationEngines/NanoGpt/NanoGptModelOptions.cs b/FictionArchive.Service.TranslationService/Services/TranslationEngines/NanoGpt/NanoGptModelOptions.cs new file mode 100644 index 0000000..9077410 --- /dev/null +++ b/FictionArchive.Service.TranslationService/Services/TranslationEngines/NanoGpt/NanoGptModelOptions.cs @@ -0,0 +1,8 @@ +namespace FictionArchive.Service.TranslationService.Services.TranslationEngines.NanoGpt; + +public sealed class NanoGptModelOptions +{ + public string Key { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string Model { get; set; } = string.Empty; +} diff --git a/FictionArchive.Service.TranslationService/Services/TranslationEngines/NanoGpt/NanoGptTranslationEngine.cs b/FictionArchive.Service.TranslationService/Services/TranslationEngines/NanoGpt/NanoGptTranslationEngine.cs new file mode 100644 index 0000000..6265bb6 --- /dev/null +++ b/FictionArchive.Service.TranslationService/Services/TranslationEngines/NanoGpt/NanoGptTranslationEngine.cs @@ -0,0 +1,109 @@ +using FictionArchive.Service.TranslationService.Models; +using FictionArchive.Service.TranslationService.Models.Enums; +using Polly; +using Language = FictionArchive.Common.Enums.Language; + +namespace FictionArchive.Service.TranslationService.Services.TranslationEngines.NanoGpt; + +public class NanoGptTranslationEngine : ITranslationEngine +{ + private const double Temperature = 0.2; + + private const string SystemPromptTemplate = + "You are a professional translator. Translate the user's message from {0} to {1}. " + + "The source text is wrapped in tags; translate only the contents of those tags. " + + "Preserve formatting (paragraphs, line breaks, punctuation). " + + "Do not add commentary, notes, or alternative translations. " + + "Output only the translated text, with no surrounding tags."; + + private readonly NanoGptApiClient _apiClient; + private readonly NanoGptModelOptions _options; + private readonly ResiliencePipeline _pipeline; + private readonly ILogger _logger; + + public NanoGptTranslationEngine( + NanoGptApiClient apiClient, + NanoGptModelOptions options, + ResiliencePipeline pipeline, + ILogger logger) + { + _apiClient = apiClient; + _options = options; + _pipeline = pipeline; + _logger = logger; + } + + public TranslationEngineDescriptor Descriptor => new() + { + DisplayName = _options.DisplayName, + Key = _options.Key + }; + + public async Task GetTranslation(string body, Language from, Language to) + { + var systemPrompt = string.Format(SystemPromptTemplate, GetLanguageName(from), GetLanguageName(to)); + var userPrompt = $"\n{body}\n"; + var messages = new List + { + new("system", systemPrompt), + new("user", userPrompt) + }; + + try + { + var response = await _pipeline.ExecuteAsync( + async ct => await _apiClient.CompleteAsync(_options.Model, messages, Temperature, ct)); + + if (response.Choices.Count == 0) + { + _logger.LogWarning("NanoGPT returned no choices for model {Model}", _options.Model); + return Failed(body, from, to); + } + + _logger.LogInformation( + "Translated text via {Model}. Tokens billed: {TotalTokens} (prompt {Prompt}, completion {Completion})", + _options.Model, + response.Usage.TotalTokens, + response.Usage.PromptTokens, + response.Usage.CompletionTokens); + + return new TranslationResult + { + OriginalText = body, + From = from, + To = to, + TranslationEngineKey = _options.Key, + BilledCharacterCount = (uint)response.Usage.TotalTokens, + Status = TranslationRequestStatus.Success, + TranslatedText = response.Choices[0].Message.Content + }; + } + catch (Exception ex) + { + _logger.LogError(ex, + "NanoGPT translation failed for model {Model} ({From} to {To})", + _options.Model, from, to); + return Failed(body, from, to); + } + } + + private TranslationResult Failed(string body, Language from, Language to) => new() + { + OriginalText = body, + From = from, + To = to, + TranslationEngineKey = _options.Key, + Status = TranslationRequestStatus.Failed, + TranslatedText = null, + BilledCharacterCount = 0 + }; + + private static string GetLanguageName(Language language) => language switch + { + Language.En => "English", + Language.Kr => "Korean", + Language.Ch => "Chinese", + Language.Ja => "Japanese", + _ => throw new ArgumentOutOfRangeException(nameof(language), language, null) + }; +} diff --git a/FictionArchive.Service.TranslationService/appsettings.json b/FictionArchive.Service.TranslationService/appsettings.json index 9d1b67d..a82de61 100644 --- a/FictionArchive.Service.TranslationService/appsettings.json +++ b/FictionArchive.Service.TranslationService/appsettings.json @@ -9,6 +9,11 @@ "DeepL": { "ApiKey": "REPLACE_ME" }, + "NanoGpt": { + "ApiKey": "REPLACE_ME", + "BaseAddress": "https://nano-gpt.com/api/", + "Models": [] + }, "ConnectionStrings": { "DefaultConnection": "Host=localhost;Database=FictionArchive_NovelService;Username=postgres;password=postgres" }, diff --git a/FictionArchive.sln b/FictionArchive.sln index 45d052b..6a39300 100644 --- a/FictionArchive.sln +++ b/FictionArchive.sln @@ -27,6 +27,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.Repo EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.ReportingService.Tests", "FictionArchive.Service.ReportingService.Tests\FictionArchive.Service.ReportingService.Tests.csproj", "{E704ACF1-2E1D-4A1C-BBCE-8FAE9F1A9944}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.TranslationService.Tests", "FictionArchive.Service.TranslationService.Tests\FictionArchive.Service.TranslationService.Tests.csproj", "{F57F475F-49C0-4454-966F-AB6A4199FF42}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -85,5 +87,9 @@ Global {E704ACF1-2E1D-4A1C-BBCE-8FAE9F1A9944}.Debug|Any CPU.Build.0 = Debug|Any CPU {E704ACF1-2E1D-4A1C-BBCE-8FAE9F1A9944}.Release|Any CPU.ActiveCfg = Release|Any CPU {E704ACF1-2E1D-4A1C-BBCE-8FAE9F1A9944}.Release|Any CPU.Build.0 = Release|Any CPU + {F57F475F-49C0-4454-966F-AB6A4199FF42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F57F475F-49C0-4454-966F-AB6A4199FF42}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F57F475F-49C0-4454-966F-AB6A4199FF42}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F57F475F-49C0-4454-966F-AB6A4199FF42}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal