Compare commits
2 Commits
feature/FA
...
feature/FA
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a056ca42ee | ||
|
|
fe83e0fe27 |
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
"--",
|
"--",
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
|
|||||||
Reference in New Issue
Block a user