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