[FA-misc] Translation engine work
This commit is contained in:
@@ -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
|
||||
public TranslationEngineDescriptor Descriptor => new()
|
||||
{
|
||||
get
|
||||
{
|
||||
return new TranslationEngineDescriptor()
|
||||
{
|
||||
DisplayName = DisplayName,
|
||||
Key = Key,
|
||||
};
|
||||
}
|
||||
}
|
||||
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,
|
||||
@@ -59,4 +64,4 @@ public class DeepLTranslationEngine : ITranslationEngine
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(language), language, null)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user