Compare commits

..

2 Commits

Author SHA1 Message Date
gamer147
d5a9529d6f [FA-misc] Translation engine work
Some checks failed
CI / build-backend (pull_request) Failing after 1m48s
CI / build-frontend (pull_request) Successful in 1m27s
2026-08-25 10:38:27 -04:00
327c03c098 Merge pull request '[FA-misc] Delete novel now authorizes based on group' (#65) from feature/FA-misc_GroupBasedAuth into master
All checks were successful
CI / build-backend (push) Successful in 1m9s
CI / build-frontend (push) Successful in 42s
Build Gateway / build-subgraphs (map[name:novel-service project:FictionArchive.Service.NovelService subgraph:Novel]) (push) Successful in 51s
Build Gateway / build-subgraphs (map[name:reporting-service project:FictionArchive.Service.ReportingService subgraph:Reporting]) (push) Successful in 45s
Build Gateway / build-subgraphs (map[name:scheduler-service project:FictionArchive.Service.SchedulerService subgraph:Scheduler]) (push) Successful in 44s
Build Gateway / build-subgraphs (map[name:translation-service project:FictionArchive.Service.TranslationService subgraph:Translation]) (push) Successful in 46s
Build Gateway / build-subgraphs (map[name:user-service project:FictionArchive.Service.UserService subgraph:User]) (push) Successful in 46s
Build Gateway / build-subgraphs (map[name:usernoveldata-service project:FictionArchive.Service.UserNovelDataService subgraph:UserNovelData]) (push) Successful in 45s
Release / build-and-push (map[dockerfile:FictionArchive.Service.FileService/Dockerfile name:file-service]) (push) Successful in 2m5s
Release / build-and-push (map[dockerfile:FictionArchive.Service.NovelService/Dockerfile name:novel-service]) (push) Successful in 2m1s
Release / build-and-push (map[dockerfile:FictionArchive.Service.ReportingService/Dockerfile name:reporting-service]) (push) Successful in 1m44s
Release / build-and-push (map[dockerfile:FictionArchive.Service.SchedulerService/Dockerfile name:scheduler-service]) (push) Successful in 1m53s
Release / build-and-push (map[dockerfile:FictionArchive.Service.TranslationService/Dockerfile name:translation-service]) (push) Successful in 1m46s
Release / build-and-push (map[dockerfile:FictionArchive.Service.UserNovelDataService/Dockerfile name:usernoveldata-service]) (push) Successful in 1m43s
Release / build-and-push (map[dockerfile:FictionArchive.Service.UserService/Dockerfile name:user-service]) (push) Successful in 1m39s
Release / build-frontend (push) Successful in 1m43s
Build Gateway / build-gateway (push) Successful in 3m33s
Reviewed-on: #65
2026-02-02 00:08:37 +00:00
14 changed files with 641 additions and 19 deletions

View File

@@ -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>

View File

@@ -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();
}
}

View File

@@ -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
}
}

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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();
}
}

View File

@@ -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
{
get
{
return new TranslationEngineDescriptor()
public TranslationEngineDescriptor Descriptor => new()
{
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,

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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)
};
}

View File

@@ -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"
},

View File

@@ -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