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