Spaces:
Runtime error
Runtime error
File size: 11,494 Bytes
3dd5c9a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 | using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Npgsql;
using TaskTrackingSystem.Shared.Localization;
var options = BackfillOptions.Parse(args);
if (options.ShowHelp)
{
BackfillOptions.PrintHelp();
return 0;
}
var connectionString = Environment.GetEnvironmentVariable("SUPABASE_CONNECTION_STRING")
?? Environment.GetEnvironmentVariable("ConnectionStrings__DefaultConnection");
if (string.IsNullOrWhiteSpace(connectionString))
{
Console.Error.WriteLine("Missing SUPABASE_CONNECTION_STRING or ConnectionStrings__DefaultConnection.");
return 2;
}
if (!options.Apply)
{
Console.WriteLine("Dry run mode: no database updates will be written. Use --apply to persist translations.");
}
var provider = new BackfillTranslationProvider();
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync(options.CancellationToken);
var targets = BackfillTarget.Create(options);
var summary = new BackfillSummary();
foreach (var target in targets)
{
var rows = await ReadRowsAsync(connection, target, options);
foreach (var batch in rows.Chunk(options.BatchSize))
{
foreach (var row in batch)
{
foreach (var field in target.Fields)
{
if (string.IsNullOrWhiteSpace(row.Values[field.Source]) || !string.IsNullOrWhiteSpace(row.Values[field.Target]))
{
continue;
}
summary.Inspected++;
var source = row.Values[field.Source]!;
var result = await provider.TranslateAsync(
source,
field.IsName ? "my" : "en",
field.IsName ? "en" : "my",
field.IsName,
options.CancellationToken);
if (!result.Success || string.IsNullOrWhiteSpace(result.TranslatedText))
{
summary.Failed++;
Console.WriteLine($"{target.Table} {row.Id} {field.Source}->{field.Target}: failed provider={result.Provider} error={result.ErrorMessage}");
continue;
}
if (!options.Apply)
{
summary.WouldUpdate++;
Console.WriteLine($"{target.Table} {row.Id} {field.Source}->{field.Target}: would update provider={result.Provider}");
continue;
}
try
{
var updated = await UpdateMissingFieldAsync(connection, target, row.Id, field, result.TranslatedText!, options.CancellationToken);
if (updated)
{
summary.Updated++;
Console.WriteLine($"{target.Table} {row.Id} {field.Source}->{field.Target}: updated provider={result.Provider}");
}
else
{
summary.Skipped++;
Console.WriteLine($"{target.Table} {row.Id} {field.Source}->{field.Target}: skipped because target was filled concurrently");
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
summary.Failed++;
Console.WriteLine($"{target.Table} {row.Id} {field.Source}->{field.Target}: failed database update error={ex.Message}");
}
}
}
}
}
Console.WriteLine($"Summary: inspected={summary.Inspected}; wouldUpdate={summary.WouldUpdate}; updated={summary.Updated}; skipped={summary.Skipped}; failed={summary.Failed}; mode={(options.Apply ? "apply" : "dry-run")}");
return 0;
static async Task<List<BackfillRow>> ReadRowsAsync(NpgsqlConnection connection, BackfillTarget target, BackfillOptions options)
{
var missing = string.Join(" OR ", target.Fields.Select(field => $"(\"{field.Target}\" IS NULL OR btrim(\"{field.Target}\") = '')"));
var sql = $"SELECT \"Id\", {string.Join(", ", target.Fields.SelectMany(field => new[] { $"\"{field.Source}\"", $"\"{field.Target}\"" }).Distinct())} FROM \"{target.Table}\" WHERE ({string.Join(" OR ", target.Fields.Select(field => $"\"{field.Source}\" IS NOT NULL AND btrim(\"{field.Source}\") <> ''"))}) AND ({missing})";
if (options.Id.HasValue) sql += " AND \"Id\" = @id";
sql += " ORDER BY \"Id\" LIMIT @limit";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("limit", options.Limit ?? int.MaxValue);
if (options.Id.HasValue) command.Parameters.AddWithValue("id", options.Id.Value);
var rows = new List<BackfillRow>();
await using var reader = await command.ExecuteReaderAsync(options.CancellationToken);
while (await reader.ReadAsync(options.CancellationToken))
{
var values = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
for (var index = 1; index < reader.FieldCount; index++)
{
values[reader.GetName(index)] = reader.IsDBNull(index) ? null : reader.GetString(index);
}
rows.Add(new(reader.GetInt64(0), values));
}
return rows;
}
static async Task<bool> UpdateMissingFieldAsync(NpgsqlConnection connection, BackfillTarget target, long id, BackfillField field, string translatedText, CancellationToken cancellationToken)
{
var sql = $"UPDATE \"{target.Table}\" SET \"{field.Target}\" = @value WHERE \"Id\" = @id AND (\"{field.Target}\" IS NULL OR btrim(\"{field.Target}\") = '')";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("value", translatedText);
command.Parameters.AddWithValue("id", id);
return await command.ExecuteNonQueryAsync(cancellationToken) == 1;
}
internal sealed record BackfillTarget(string Table, IReadOnlyList<BackfillField> Fields)
{
public static IReadOnlyList<BackfillTarget> Create(BackfillOptions options)
{
var all = new List<BackfillTarget>
{
new("Projects", [new("Name", "NameMy"), new("Description", "DescriptionMy")]),
new("Tasks", [new("Title", "TitleMy"), new("Description", "DescriptionMy")]),
new("Issues", [new("Title", "TitleMy"), new("Description", "DescriptionMy"), new("DelayReason", "DelayReasonMy"), new("BlockedBy", "BlockedByMy")]),
new("Users", [new("FirstName", "FirstNameMy", true), new("LastName", "LastNameMy", true)]),
new("Roles", [new("Name", "NameMy"), new("Description", "DescriptionMy")]),
new("notifications", [new("Title", "TitleMy"), new("Body", "BodyMy")])
};
if (options.IncludeComments) all.Add(new("Comments", [new("Message", "MessageMy")]));
return options.Table is null ? all : all.Where(target => string.Equals(target.Table, options.Table, StringComparison.OrdinalIgnoreCase)).ToArray();
}
}
internal sealed record BackfillField(string Source, string Target, bool IsName = false);
internal sealed record BackfillRow(long Id, Dictionary<string, string?> Values);
internal sealed class BackfillSummary { public int Inspected; public int WouldUpdate; public int Updated; public int Skipped; public int Failed; }
internal sealed class BackfillOptions
{
public bool Apply { get; private set; }
public bool IncludeComments { get; private set; }
public bool ShowHelp { get; private set; }
public string? Table { get; private set; }
public int? Limit { get; private set; }
public int BatchSize { get; private set; } = 20;
public long? Id { get; private set; }
public CancellationToken CancellationToken => cancellationSource.Token;
private readonly CancellationTokenSource cancellationSource = new();
public static BackfillOptions Parse(string[] args)
{
var options = new BackfillOptions();
Console.CancelKeyPress += (_, eventArgs) => { eventArgs.Cancel = true; options.cancellationSource.Cancel(); };
for (var i = 0; i < args.Length; i++)
{
switch (args[i].ToLowerInvariant())
{
case "--apply": options.Apply = true; break;
case "--dry-run": options.Apply = false; break;
case "--include-comments": options.IncludeComments = true; break;
case "--table" when i + 1 < args.Length: options.Table = args[++i]; break;
case "--limit" when i + 1 < args.Length && int.TryParse(args[++i], out var limit): options.Limit = Math.Max(1, limit); break;
case "--batch-size" when i + 1 < args.Length && int.TryParse(args[++i], out var batchSize): options.BatchSize = Math.Max(1, batchSize); break;
case "--id" when i + 1 < args.Length && long.TryParse(args[++i], out var id): options.Id = id; break;
case "--help" or "-h": options.ShowHelp = true; break;
}
}
return options;
}
public static void PrintHelp() => Console.WriteLine("Usage: dotnet run --project Tools/TranslationBackfill -- --dry-run|--apply [--table Projects] [--batch-size 20] [--limit 100] [--id 123] [--include-comments]");
}
internal sealed class BackfillTranslationProvider
{
private readonly HttpClient client = new();
private readonly string? endpoint = Environment.GetEnvironmentVariable("TRANSLATION_API_URL");
private readonly string? apiKey = Environment.GetEnvironmentVariable("TRANSLATION_API_KEY");
private readonly string provider = Environment.GetEnvironmentVariable("TRANSLATION_PROVIDER") ?? "configured-http";
public async Task<TranslationResult> TranslateAsync(string sourceText, string sourceLanguage, string targetLanguage, bool isName, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(endpoint)) return TranslationResult.NotConfigured();
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint);
if (!string.IsNullOrWhiteSpace(apiKey)) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
request.Content = JsonContent.Create(new { text = sourceText, sourceLanguage, targetLanguage, mode = isName ? "transliteration" : "translation", instruction = isName ? "Return the name only; transliterate pronunciation, do not translate meaning." : "Return translated text only; preserve names, codes, IDs, dates, numbers, placeholders, URLs, and appropriate technical terms." });
using var response = await client.SendAsync(request, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode) return new(false, null, $"Provider returned {(int)response.StatusCode}.", provider, false);
try
{
using var document = JsonDocument.Parse(body);
var root = document.RootElement;
var text = root.TryGetProperty("translatedText", out var translated) ? translated.GetString() : root.TryGetProperty("text", out var plain) ? plain.GetString() : root.TryGetProperty("translation", out var alternate) ? alternate.GetString() : null;
return string.IsNullOrWhiteSpace(text) ? new(false, null, "Provider returned no translated text.", provider, false) : TranslationResult.Generated(text.Trim(), provider);
}
catch (JsonException) { return new(false, null, "Provider returned invalid JSON.", provider, false); }
}
}
|