Compare commits

..

2 Commits

Author SHA1 Message Date
gamer147
a056ca42ee [FA-misc] Hopefully fix build issues
All checks were successful
CI / build-backend (pull_request) Successful in 1m54s
CI / build-frontend (pull_request) Successful in 54s
2026-04-26 16:11:13 -04:00
gamer147
fe83e0fe27 [FA-misc] Swaps out openvpn container for ExpressVPN
Some checks failed
CI / build-backend (pull_request) Failing after 2m11s
CI / build-frontend (pull_request) Successful in 55s
2026-04-26 15:38:16 -04:00
19 changed files with 34 additions and 650 deletions

View File

@@ -95,6 +95,9 @@ NOVELPIA_USERNAME=your-username
NOVELPIA_PASSWORD=your-password NOVELPIA_PASSWORD=your-password
DEEPL_API_KEY=your-api-key DEEPL_API_KEY=your-api-key
# ExpressVPN (used by the `vpn` container that tunnels novel-service into Korea)
EXPRESSVPN_ACTIVATION_CODE=your-expressvpn-activation-code
# S3 Storage # S3 Storage
S3_ENDPOINT=https://s3.example.com S3_ENDPOINT=https://s3.example.com
S3_BUCKET=fictionarchive S3_BUCKET=fictionarchive

View File

@@ -98,6 +98,7 @@ for svc in selected_services:
# Export schema # Export schema
run([ run([
"dotnet", "run", "dotnet", "run",
"-c", "Release",
"--no-build", "--no-build",
"--no-launch-profile", "--no-launch-profile",
"--", "--",

View File

@@ -9,7 +9,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" /> <PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="MassTransit" Version="8.5.7" /> <PackageReference Include="MassTransit" Version="8.5.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.11" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="NodaTime.Testing" Version="3.3.0" /> <PackageReference Include="NodaTime.Testing" Version="3.3.0" />

View File

@@ -9,7 +9,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" /> <PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="MassTransit" Version="8.5.7" /> <PackageReference Include="MassTransit" Version="8.5.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.11" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="NodaTime.Testing" Version="3.3.0" /> <PackageReference Include="NodaTime.Testing" Version="3.3.0" />

View File

@@ -1,29 +0,0 @@
<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

@@ -1,187 +0,0 @@
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

@@ -1,127 +0,0 @@
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

@@ -1,25 +0,0 @@
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,7 +22,6 @@
</PackageReference> </PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="DeepL.net" Version="1.17.0" /> <PackageReference Include="DeepL.net" Version="1.17.0" />
<PackageReference Include="Polly" Version="8.6.5" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
</ItemGroup> </ItemGroup>

View File

@@ -9,10 +9,6 @@ using FictionArchive.Service.TranslationService.Services;
using FictionArchive.Service.TranslationService.Services.Database; using FictionArchive.Service.TranslationService.Services.Database;
using FictionArchive.Service.TranslationService.Services.TranslationEngines; using FictionArchive.Service.TranslationService.Services.TranslationEngines;
using FictionArchive.Service.TranslationService.Services.TranslationEngines.DeepLTranslate; 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; namespace FictionArchive.Service.TranslationService;
@@ -59,43 +55,11 @@ public class Program
#region Translation Adapter #region Translation Adapter
// Shared resilience pipeline used by both DeepL and NanoGPT engines. builder.Services.AddTransient<DeepLClient>(provider =>
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"]; return new DeepLClient(builder.Configuration["DeepL:ApiKey"]);
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);
}); });
builder.Services.AddTransient<ITranslationEngine, DeepLTranslationEngine>();
// 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>(); builder.Services.AddTransient<TranslationEngineService>();

View File

@@ -1,26 +0,0 @@
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,7 +2,6 @@ using DeepL;
using DeepL.Model; using DeepL.Model;
using FictionArchive.Service.TranslationService.Models; using FictionArchive.Service.TranslationService.Models;
using FictionArchive.Service.TranslationService.Models.Enums; using FictionArchive.Service.TranslationService.Models.Enums;
using Polly;
using Language = FictionArchive.Common.Enums.Language; using Language = FictionArchive.Common.Enums.Language;
namespace FictionArchive.Service.TranslationService.Services.TranslationEngines.DeepLTranslate; namespace FictionArchive.Service.TranslationService.Services.TranslationEngines.DeepLTranslate;
@@ -10,38 +9,34 @@ namespace FictionArchive.Service.TranslationService.Services.TranslationEngines.
public class DeepLTranslationEngine : ITranslationEngine public class DeepLTranslationEngine : ITranslationEngine
{ {
private readonly DeepLClient _deepLClient; private readonly DeepLClient _deepLClient;
private readonly ResiliencePipeline _pipeline;
private readonly ILogger<DeepLTranslationEngine> _logger; private readonly ILogger<DeepLTranslationEngine> _logger;
private const string DisplayName = "DeepL"; private const string DisplayName = "DeepL";
private const string Key = "deepl"; private const string Key = "deepl";
public DeepLTranslationEngine( public DeepLTranslationEngine(DeepLClient deepLClient, ILogger<DeepLTranslationEngine> logger)
DeepLClient deepLClient,
ResiliencePipeline pipeline,
ILogger<DeepLTranslationEngine> logger)
{ {
_deepLClient = deepLClient; _deepLClient = deepLClient;
_pipeline = pipeline;
_logger = logger; _logger = logger;
} }
public TranslationEngineDescriptor Descriptor => new() public TranslationEngineDescriptor Descriptor
{ {
DisplayName = DisplayName, get
Key = Key, {
}; return new TranslationEngineDescriptor()
{
DisplayName = DisplayName,
Key = Key,
};
}
}
public async Task<TranslationResult> GetTranslation(string body, Language from, Language to) public async Task<TranslationResult> GetTranslation(string body, Language from, Language to)
{ {
TextResult translationResult = await _pipeline.ExecuteAsync( TextResult translationResult = await _deepLClient.TranslateTextAsync(body, GetLanguageCode(from), GetLanguageCode(to));
async _ => await _deepLClient.TranslateTextAsync(body, GetLanguageCode(from), GetLanguageCode(to))); _logger.LogInformation("Translated text. Usage statistics: CHARACTERS BILLED {TranslationResultBilledCharacters}", translationResult.BilledCharacters);
return new TranslationResult()
_logger.LogInformation(
"Translated text. Usage statistics: CHARACTERS BILLED {TranslationResultBilledCharacters}",
translationResult.BilledCharacters);
return new TranslationResult
{ {
OriginalText = body, OriginalText = body,
From = from, From = from,
@@ -64,4 +59,4 @@ public class DeepLTranslationEngine : ITranslationEngine
_ => throw new ArgumentOutOfRangeException(nameof(language), language, null) _ => throw new ArgumentOutOfRangeException(nameof(language), language, null)
}; };
} }
} }

View File

@@ -1,34 +0,0 @@
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

@@ -1,24 +0,0 @@
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

@@ -1,8 +0,0 @@
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

@@ -1,109 +0,0 @@
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,11 +9,6 @@
"DeepL": { "DeepL": {
"ApiKey": "REPLACE_ME" "ApiKey": "REPLACE_ME"
}, },
"NanoGpt": {
"ApiKey": "REPLACE_ME",
"BaseAddress": "https://nano-gpt.com/api/",
"Models": []
},
"ConnectionStrings": { "ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=FictionArchive_NovelService;Username=postgres;password=postgres" "DefaultConnection": "Host=localhost;Database=FictionArchive_NovelService;Username=postgres;password=postgres"
}, },

View File

@@ -27,8 +27,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.Repo
EndProject 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}" 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 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -87,9 +85,5 @@ Global
{E704ACF1-2E1D-4A1C-BBCE-8FAE9F1A9944}.Debug|Any CPU.Build.0 = Debug|Any CPU {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.ActiveCfg = Release|Any CPU
{E704ACF1-2E1D-4A1C-BBCE-8FAE9F1A9944}.Release|Any CPU.Build.0 = 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 EndGlobalSection
EndGlobal EndGlobal

View File

@@ -43,7 +43,7 @@ services:
# VPN Container # VPN Container
# =========================================== # ===========================================
vpn: vpn:
image: dperson/openvpn-client image: misioslav/expressvpn:latest
networks: networks:
fictionarchive: fictionarchive:
ipv4_address: 172.20.0.20 ipv4_address: 172.20.0.20
@@ -51,23 +51,25 @@ services:
- novel-service - novel-service
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
- SYS_PTRACE
devices: devices:
- /dev/net/tun - /dev/net/tun
volumes:
- /srv/docker_volumes/korean_vpn:/vpn
dns:
- 192.168.3.1
environment: environment:
- DNS=1.1.1.1,8.8.8.8 CODE: ${EXPRESSVPN_ACTIVATION_CODE}
SERVER: krsi
PROTOCOL: lightwayudp
WHITELIST_DNS: 1.1.1.1,8.8.8.8
CONNECTION_CHECK_INTERVAL: 30
RECONNECT_FAILURE_THRESHOLD: 3
extra_hosts: extra_hosts:
- "postgres:172.20.0.10" - "postgres:172.20.0.10"
- "rabbitmq:172.20.0.11" - "rabbitmq:172.20.0.11"
healthcheck: healthcheck:
test: ["CMD", "ping", "-c", "1", "-W", "5", "1.1.1.1"] test: ["CMD-SHELL", "test ! -f /tmp/expressvpn/reconnect-failure.flag && expressvpnctl status | grep -q Connected"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 30s start_period: 60s
restart: unless-stopped restart: unless-stopped
# =========================================== # ===========================================