[FA-misc] Translation engine work
Some checks failed
CI / build-backend (pull_request) Failing after 1m48s
CI / build-frontend (pull_request) Successful in 1m27s

This commit is contained in:
gamer147
2026-08-25 10:38:27 -04:00
parent 327c03c098
commit d5a9529d6f
14 changed files with 641 additions and 19 deletions

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