Compare commits
1 Commits
v1.5.2
...
feature/FA
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5a9529d6f |
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="NSubstitute" Version="5.1.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\FictionArchive.Service.TranslationService\FictionArchive.Service.TranslationService.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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<HttpRequestMessage, HttpResponseMessage> 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<NanoGptApiClient>.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<NanoGptTranslationEngine>.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("<source_text>").And.Contain("Output only the translated text");
|
||||
|
||||
var user = messages[1];
|
||||
user.GetProperty("role").GetString().Should().Be("user");
|
||||
user.GetProperty("content").GetString().Should().Be("<source_text>\nHello\n</source_text>");
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
}
|
||||
@@ -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<ArgumentException>();
|
||||
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<AuthorizationException>();
|
||||
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<HttpRequestException>();
|
||||
attempts.Should().Be(4); // initial attempt + 3 retries
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace FictionArchive.Service.TranslationService.Tests.TestSupport;
|
||||
|
||||
public sealed class TestHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
|
||||
|
||||
public List<HttpRequestMessage> Requests { get; } = new();
|
||||
public List<string> RequestBodies { get; } = new();
|
||||
|
||||
public TestHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder)
|
||||
{
|
||||
_responder = responder;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> 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);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||
<PackageReference Include="DeepL.net" Version="1.17.0" />
|
||||
<PackageReference Include="Polly" Version="8.6.5" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -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<DeepLClient>(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<DeepLClient>(_ => new DeepLClient(builder.Configuration["DeepL:ApiKey"]));
|
||||
builder.Services.AddTransient<ITranslationEngine, DeepLTranslationEngine>();
|
||||
|
||||
// NanoGPT — typed HttpClient (bearer auth + base address + 120s timeout).
|
||||
builder.Services.AddHttpClient<NanoGptApiClient>(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<NanoGptModelOptions[]>() ?? [];
|
||||
foreach (var modelOptions in nanoGptModels)
|
||||
{
|
||||
builder.Services.AddTransient<ITranslationEngine>(sp => new NanoGptTranslationEngine(
|
||||
sp.GetRequiredService<NanoGptApiClient>(),
|
||||
modelOptions,
|
||||
sp.GetRequiredService<ResiliencePipeline>(),
|
||||
sp.GetRequiredService<ILogger<NanoGptTranslationEngine>>()));
|
||||
}
|
||||
|
||||
builder.Services.AddTransient<TranslationEngineService>();
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -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<HttpRequestException>()
|
||||
.Handle<TaskCanceledException>(ex => ex.InnerException is TimeoutException)
|
||||
.Handle<ConnectionException>()
|
||||
.Handle<TooManyRequestsException>(),
|
||||
MaxRetryAttempts = 3,
|
||||
BackoffType = DelayBackoffType.Exponential,
|
||||
Delay = TimeSpan.FromMilliseconds(500),
|
||||
UseJitter = true
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
@@ -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<DeepLTranslationEngine> _logger;
|
||||
|
||||
private const string DisplayName = "DeepL";
|
||||
private const string Key = "deepl";
|
||||
|
||||
public DeepLTranslationEngine(DeepLClient deepLClient, ILogger<DeepLTranslationEngine> logger)
|
||||
public DeepLTranslationEngine(
|
||||
DeepLClient deepLClient,
|
||||
ResiliencePipeline pipeline,
|
||||
ILogger<DeepLTranslationEngine> 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<TranslationResult> 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,
|
||||
|
||||
@@ -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<NanoGptApiClient> _logger;
|
||||
|
||||
public NanoGptApiClient(HttpClient httpClient, ILogger<NanoGptApiClient> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<NanoGptChatResponse> CompleteAsync(
|
||||
string model,
|
||||
IReadOnlyList<NanoGptMessage> 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<NanoGptChatResponse>(ct);
|
||||
if (body is null)
|
||||
{
|
||||
throw new InvalidOperationException("NanoGPT returned an empty response body.");
|
||||
}
|
||||
return body;
|
||||
}
|
||||
}
|
||||
@@ -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<NanoGptMessage> Messages,
|
||||
[property: JsonPropertyName("temperature")] double Temperature);
|
||||
|
||||
public sealed record NanoGptChatResponse(
|
||||
[property: JsonPropertyName("choices")] IReadOnlyList<NanoGptChoice> 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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 <source_text> 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<NanoGptTranslationEngine> _logger;
|
||||
|
||||
public NanoGptTranslationEngine(
|
||||
NanoGptApiClient apiClient,
|
||||
NanoGptModelOptions options,
|
||||
ResiliencePipeline pipeline,
|
||||
ILogger<NanoGptTranslationEngine> logger)
|
||||
{
|
||||
_apiClient = apiClient;
|
||||
_options = options;
|
||||
_pipeline = pipeline;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public TranslationEngineDescriptor Descriptor => new()
|
||||
{
|
||||
DisplayName = _options.DisplayName,
|
||||
Key = _options.Key
|
||||
};
|
||||
|
||||
public async Task<TranslationResult> GetTranslation(string body, Language from, Language to)
|
||||
{
|
||||
var systemPrompt = string.Format(SystemPromptTemplate, GetLanguageName(from), GetLanguageName(to));
|
||||
var userPrompt = $"<source_text>\n{body}\n</source_text>";
|
||||
var messages = new List<NanoGptMessage>
|
||||
{
|
||||
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)
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user