[FA-misc] Translation engine work
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user