improve alarm diagnosis review service

This commit is contained in:
Yinyin Liu 2026-02-25 20:08:40 +01:00
parent 8de43276a0
commit e72f16f26b
5 changed files with 549 additions and 244 deletions

View File

@ -1278,6 +1278,27 @@ public class Controller : ControllerBase
return Ok(new { message = "Campaign stopped and progress file deleted. Safe to redeploy." });
}
[HttpGet(nameof(CorrectAlarm))]
public ActionResult CorrectAlarm(int batch, string key)
{
var html = AlarmReviewService.GetCorrectionPage(batch, key);
return Content(html, "text/html");
}
[HttpPost(nameof(ApplyAlarmCorrection))]
public ActionResult ApplyAlarmCorrection([FromBody] AlarmCorrectionRequest req)
{
if (req == null) return BadRequest();
var correction = new DiagnosticResponse
{
Explanation = req.Explanation ?? "",
Causes = req.Causes ?? new List<string>(),
NextSteps = req.NextSteps ?? new List<string>(),
};
var ok = AlarmReviewService.ApplyCorrection(req.BatchNumber, req.AlarmKey ?? "", correction);
return ok ? Ok(new { message = "Korrektur gespeichert." }) : BadRequest("Batch or alarm not found.");
}
[HttpGet(nameof(ReviewAlarms))]
public ActionResult ReviewAlarms(int batch, string reviewer)
{

View File

@ -27,6 +27,7 @@ public class BatchRecord
[JsonProperty("synthesizedAt")] public string? SynthesizedAt { get; set; }
[JsonProperty("submissions")] public Dictionary<string, ReviewerSubmission?> Submissions { get; set; } = new();
[JsonProperty("improvedEntries")] public Dictionary<string, DiagnosticResponse> ImprovedEntries{ get; set; } = new();
[JsonProperty("note")] public string? Note { get; set; }
}
public class ReviewerSubmission
@ -46,6 +47,15 @@ public class ReviewFeedback
[JsonProperty("comment")] public string Comment { get; set; } = "";
}
public class AlarmCorrectionRequest
{
[JsonProperty("batchNumber")] public int BatchNumber { get; set; }
[JsonProperty("alarmKey")] public string? AlarmKey { get; set; }
[JsonProperty("explanation")] public string? Explanation { get; set; }
[JsonProperty("causes")] public List<string>? Causes { get; set; }
[JsonProperty("nextSteps")] public List<string>? NextSteps { get; set; }
}
// ── Service ───────────────────────────────────────────────────────────────────
public static class AlarmReviewService
@ -68,7 +78,7 @@ public static class AlarmReviewService
private static string ResourcesDir => Path.Combine(AppContext.BaseDirectory, "Resources");
private static string ProgressFile => Path.Combine(ResourcesDir, "alarm-review-progress.json");
private static string CheckedFilePath => Path.Combine(AppContext.BaseDirectory, "AlarmKnowledgeBaseChecked.cs");
private static string CheckedFilePath => Path.Combine(ResourcesDir, "AlarmTranslationsChecked.de.json");
// ── German alarm display names (loaded from AlarmNames.de.json) ────────────
@ -228,6 +238,11 @@ public static class AlarmReviewService
private static async Task RunMorningJobAsync()
{
if (!IsWorkday(DateTime.Now))
{
Console.WriteLine("[AlarmReviewService] 8AM job skipped — weekend or public holiday.");
return;
}
Console.WriteLine("[AlarmReviewService] Running 8AM morning job...");
var progress = LoadProgress();
if (progress == null) return;
@ -237,30 +252,64 @@ public static class AlarmReviewService
if (current.Synthesized)
{
// Next batch is sent immediately after synthesis — only act here as a safety net
// in case the server restarted before SendNextBatchAsync could run.
var nextAlreadySent = progress.Batches.Count > current.BatchNumber;
if (!nextAlreadySent)
{
Console.WriteLine($"[AlarmReviewService] Batch {current.BatchNumber} was synthesized but next batch not sent — sending now (recovery).");
await SendNextBatchAsync(progress);
}
}
else
{
var submissionCount = current.Submissions.Values.Count(s => s != null);
if (submissionCount == 0)
{
current.ResendCount++;
const int MaxResends = 3;
if (current.ResendCount >= MaxResends)
{
// No responses after 3 resends — auto-advance using original content
Console.WriteLine($"[AlarmReviewService] Batch {current.BatchNumber}: still 0 submissions after {MaxResends} resends — auto-advancing with original content.");
foreach (var key in current.AlarmKeys)
{
var original = DiagnosticService.TryGetTranslation(key, "de") ?? AlarmKnowledgeBase.TryGetDiagnosis(key);
if (original != null) current.ImprovedEntries[key] = original;
}
current.Synthesized = true;
current.SynthesizedAt = DateTime.UtcNow.ToString("O");
current.Note = "Auto-advanced after 3 resends with no reviewer responses — original content preserved.";
SaveProgress(progress);
Console.WriteLine($"[AlarmReviewService] Batch {current.BatchNumber}: 0 submissions — resending (attempt #{current.ResendCount}).");
await SendBatchEmailsAsync(current, isResend: true);
RegenerateCheckedFile(progress);
await SendAdminStallAlertAsync(current);
await SendNextBatchAsync(progress);
}
else
{
current.ResendCount++;
SaveProgress(progress);
Console.WriteLine($"[AlarmReviewService] Batch {current.BatchNumber}: 0 submissions — resending (attempt #{current.ResendCount}/{MaxResends}).");
await SendBatchEmailsAsync(current, isResend: true);
await SendAdminStallAlertAsync(current);
}
}
else
{
// SynthesizeBatchAsync will call SendNextBatchAsync internally when done
await SynthesizeBatchAsync(current, progress);
await SendNextBatchAsync(progress);
}
}
}
private static async Task SendNextBatchAsync(AlarmReviewProgress progress)
{
if (!IsWorkday(DateTime.Now))
{
Console.WriteLine("[AlarmReviewService] Synthesis complete but today is not a workday — next batch will be sent on the next working day.");
return; // Morning job recovery will send the next batch on the next workday
}
var nextStartIndex = progress.Batches.Count * BatchSize;
if (nextStartIndex >= AllAlarmKeys.Length)
{
@ -283,6 +332,11 @@ public static class AlarmReviewService
private static async Task RunAfternoonJobAsync()
{
if (!IsWorkday(DateTime.Now))
{
Console.WriteLine("[AlarmReviewService] 2PM job skipped — weekend or public holiday.");
return;
}
Console.WriteLine("[AlarmReviewService] Running 2PM afternoon job...");
var progress = LoadProgress();
if (progress == null) return;
@ -509,7 +563,7 @@ public static class AlarmReviewService
// Run all synthesis calls in parallel
var synthTasks = testBatch.AlarmKeys.Select(async (key, i) =>
{
var original = AlarmKnowledgeBase.TryGetDiagnosis(key);
var original = DiagnosticService.TryGetTranslation(key, "de") ?? AlarmKnowledgeBase.TryGetDiagnosis(key);
var fb = feedbacks[i];
var anyChanges = !fb.ExplanationOk || !fb.CausesOk || !fb.StepsOk;
@ -625,7 +679,96 @@ public static class AlarmReviewService
};
}
/// <summary>Returns the generated AlarmKnowledgeBaseChecked.cs content for download.</summary>
/// <summary>Returns an HTML correction form pre-filled with the current synthesized content for a specific alarm.</summary>
public static string GetCorrectionPage(int batchNumber, string alarmKey)
{
var progress = LoadProgress();
var batch = progress?.Batches.FirstOrDefault(b => b.BatchNumber == batchNumber);
if (batch == null || !batch.ImprovedEntries.TryGetValue(alarmKey, out var entry))
return "<html><body><h3>Alarm not found.</h3></body></html>";
var label = GermanName(alarmKey);
var causesJson = Newtonsoft.Json.JsonConvert.SerializeObject(entry.Causes);
var stepsJson = Newtonsoft.Json.JsonConvert.SerializeObject(entry.NextSteps);
var submitUrl = $"{BaseUrl}/ApplyAlarmCorrection";
return $$"""
<!DOCTYPE html><html lang="de">
<head><meta charset="utf-8"><title>Korrektur {{System.Web.HttpUtility.HtmlEncode(label)}}</title>
<style>
body{font-family:Arial,sans-serif;max-width:640px;margin:32px auto;padding:0 16px;color:#333}
h2{color:#2c3e50}label{font-weight:bold;font-size:13px;display:block;margin:14px 0 4px}
textarea,input{width:100%;box-sizing:border-box;border:1px solid #ddd;border-radius:4px;padding:8px;font-size:13px;font-family:inherit}
textarea{resize:vertical}.li-row{display:flex;gap:6px;margin-bottom:5px}
.li-row textarea{flex:1;min-height:34px}.btn-rm{background:#e74c3c;color:#fff;border:none;border-radius:4px;width:26px;cursor:pointer}
.btn-add{background:#3498db;color:#fff;border:none;border-radius:4px;padding:5px 12px;cursor:pointer;font-size:12px;margin-top:4px}
.btn-save{background:#27ae60;color:#fff;border:none;border-radius:6px;padding:11px 28px;font-size:15px;cursor:pointer;margin-top:20px;font-weight:bold}
.hdr{background:#2c3e50;color:#fff;padding:16px 20px;border-radius:8px;margin-bottom:24px}
</style></head>
<body>
<div class="hdr"><div style="font-size:16px;font-weight:bold">Alarm-Korrektur</div><div style="font-size:13px;opacity:.8;margin-top:4px">{{System.Web.HttpUtility.HtmlEncode(label)}}</div></div>
<label>Erklärung</label>
<textarea id="exp" rows="3">{{System.Web.HttpUtility.HtmlEncode(entry.Explanation)}}</textarea>
<label>Mögliche Ursachen</label>
<div id="causes-ed"></div>
<label>Was zu tun ist</label>
<div id="steps-ed"></div>
<button class="btn-save" onclick="save()">Korrektur speichern</button>
<div id="msg" style="margin-top:14px;font-size:13px"></div>
<script>
var causes = {{causesJson}};
var steps = {{stepsJson}};
function renderList(type){
var arr = type==='causes'?causes:steps;
var ed = document.getElementById(type+'-ed');
ed.innerHTML = arr.map(function(v,i){
return '<div class="li-row"><textarea onchange="upd(\''+type+'\','+i+',this.value)">'+esc(v)+'</textarea>'+
'<button class="btn-rm" onclick="rm(\''+type+'\','+i+')">&#x2715;</button></div>';
}).join('')+'<button class="btn-add" onclick="add(\''+type+'\')">+ Hinzufügen</button>';
}
function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function upd(t,i,v){(t==='causes'?causes:steps)[i]=v;}
function rm(t,i){(t==='causes'?causes:steps).splice(i,1);renderList(t);}
function add(t){(t==='causes'?causes:steps).push('');renderList(t);}
renderList('causes'); renderList('steps');
function save(){
fetch('{{submitUrl}}',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({batchNumber:{{batchNumber}},alarmKey:'{{alarmKey}}',
explanation:document.getElementById('exp').value,causes:causes,nextSteps:steps})})
.then(function(r){return r.json();})
.then(function(d){document.getElementById('msg').innerHTML='<span style="color:#27ae60"> '+d.message+'</span>';})
.catch(function(){document.getElementById('msg').innerHTML='<span style="color:#e74c3c">Fehler beim Speichern</span>';});
}
</script></body></html>
""";
}
/// <summary>Applies an admin correction to a specific alarm entry and regenerates the checked file.</summary>
public static bool ApplyCorrection(int batchNumber, string alarmKey, DiagnosticResponse correction)
{
if (string.IsNullOrWhiteSpace(correction.Explanation) ||
correction.Causes == null || correction.Causes.Count == 0 ||
correction.NextSteps == null || correction.NextSteps.Count == 0)
return false;
lock (_fileLock)
{
var progress = LoadProgress();
var batch = progress?.Batches.FirstOrDefault(b => b.BatchNumber == batchNumber);
if (batch == null) return false;
batch.ImprovedEntries[alarmKey] = correction;
SaveProgress(progress!);
}
// Regenerate outside the lock — reads progress fresh
var prog = LoadProgress();
if (prog != null) RegenerateCheckedFile(prog);
Console.WriteLine($"[AlarmReviewService] Admin correction applied for {alarmKey} (batch {batchNumber}).");
return true;
}
/// <summary>Returns the generated AlarmTranslationsChecked.de.json content for download.</summary>
public static string? GetCheckedFileContent()
{
if (!File.Exists(CheckedFilePath)) return null;
@ -653,7 +796,7 @@ public static class AlarmReviewService
for (int i = 0; i < batch.AlarmKeys.Count; i++)
{
var alarmKey = batch.AlarmKeys[i];
var original = AlarmKnowledgeBase.TryGetDiagnosis(alarmKey);
var original = DiagnosticService.TryGetTranslation(alarmKey, "de") ?? AlarmKnowledgeBase.TryGetDiagnosis(alarmKey);
if (original == null) continue;
var feedbacks = submissions
@ -663,17 +806,17 @@ public static class AlarmReviewService
var anyChanges = feedbacks.Any(f => !f.ExplanationOk || !f.CausesOk || !f.StepsOk);
DiagnosticResponse? improved = null;
if (anyChanges)
improved = await CallMistralForSynthesisAsync(alarmKey, original, feedbacks);
// Fall back to original if Mistral returned nothing or no changes needed
batch.ImprovedEntries[alarmKey] = improved ?? new DiagnosticResponse
if (!anyChanges)
{
Explanation = original.Explanation,
Causes = original.Causes,
NextSteps = original.NextSteps,
};
batch.ImprovedEntries[alarmKey] = original;
continue;
}
var improved = await CallMistralForSynthesisAsync(alarmKey, original, feedbacks);
// If AI output is rejected by validation, fall back to reviewer's direct edit
// (not the original) so reviewer changes are always respected.
batch.ImprovedEntries[alarmKey] = improved ?? BuildReviewerDirectEdit(original, feedbacks);
}
batch.Synthesized = true;
@ -691,6 +834,9 @@ public static class AlarmReviewService
var totalReviewed = progress.Batches.Count(b => b.Synthesized) * BatchSize;
await SendAdminDailySummaryAsync(batch, Math.Min(totalReviewed, AllAlarmKeys.Length));
// Send next batch immediately after synthesis — no need to wait for 8AM
await SendNextBatchAsync(progress);
}
finally
{
@ -715,10 +861,10 @@ public static class AlarmReviewService
{
var requestBody = new
{
model = "mistral-small-latest",
model = "mistral-large-latest",
messages = new[] { new { role = "user", content = prompt } },
max_tokens = 500,
temperature = 0.2,
max_tokens = 600,
temperature = 0.1,
};
var responseText = await MistralUrl
@ -739,7 +885,8 @@ public static class AlarmReviewService
json = json.Trim();
}
return JsonConvert.DeserializeObject<DiagnosticResponse>(json);
var result = JsonConvert.DeserializeObject<DiagnosticResponse>(json);
return ValidateSynthesisOutput(result, feedbacks, alarmKey);
}
catch (Exception ex)
{
@ -788,13 +935,14 @@ public static class AlarmReviewService
sb.AppendLine();
sb.AppendLine("SYNTHESIS RULES:");
sb.AppendLine("- Use reviewer changes where provided (they have direct support experience)");
sb.AppendLine("- If multiple reviewers changed the same section, synthesize the best version");
sb.AppendLine("- Language target: German (Deutsch), simple plain language for homeowners, NOT technical jargon");
sb.AppendLine("- Output MUST be in German regardless of the original content language");
sb.AppendLine("- Explanation: exactly 1 sentence, max 25 words");
sb.AppendLine("- Causes: 24 bullets, plain language");
sb.AppendLine("- Next Steps: 24 action items, easiest/most accessible check first");
sb.AppendLine("- The original content is already in German. Output must also be in German.");
sb.AppendLine("- Use simple plain language suitable for homeowners, NOT technical jargon.");
sb.AppendLine("- For each section (Explanation / Causes / Next Steps):");
sb.AppendLine(" * If reviewer marked it 'Approved as-is': keep the original text exactly, no changes.");
sb.AppendLine(" * If reviewer provided a changed list: output EXACTLY those items — same count, same meaning.");
sb.AppendLine(" DO NOT add, remove, merge, or invent any bullets. Only improve the German phrasing.");
sb.AppendLine("- If multiple reviewers changed the same section, pick the best version or merge carefully.");
sb.AppendLine("- Explanation: 1 sentence, max 25 words.");
sb.AppendLine();
sb.AppendLine("Reply with ONLY valid JSON, no markdown fences:");
sb.AppendLine("{\"explanation\":\"...\",\"causes\":[\"...\"],\"nextSteps\":[\"...\"]}");
@ -802,7 +950,81 @@ public static class AlarmReviewService
return sb.ToString();
}
// ── AlarmKnowledgeBaseChecked.cs generation ────────────────────────────────
/// <summary>
/// Validates AI synthesis output. Returns null (reject) if output is structurally bad
/// so the caller can fall back to the reviewer's direct edit instead.
/// </summary>
private static DiagnosticResponse? ValidateSynthesisOutput(
DiagnosticResponse? result, List<ReviewFeedback> feedbacks, string alarmKey)
{
if (result == null) return null;
// Must have non-empty content
if (string.IsNullOrWhiteSpace(result.Explanation))
{
Console.Error.WriteLine($"[AlarmReviewService] Validation failed for {alarmKey}: empty explanation.");
return null;
}
if (result.Causes == null || result.Causes.Count == 0)
{
Console.Error.WriteLine($"[AlarmReviewService] Validation failed for {alarmKey}: empty causes.");
return null;
}
if (result.NextSteps == null || result.NextSteps.Count == 0)
{
Console.Error.WriteLine($"[AlarmReviewService] Validation failed for {alarmKey}: empty nextSteps.");
return null;
}
// If reviewer changed causes, output must have the exact same count
var causesFeedback = feedbacks.FirstOrDefault(f => !f.CausesOk);
if (causesFeedback != null && result.Causes.Count != causesFeedback.Causes.Count)
{
Console.Error.WriteLine($"[AlarmReviewService] Validation failed for {alarmKey}: causes count mismatch " +
$"(expected {causesFeedback.Causes.Count}, got {result.Causes.Count}).");
return null;
}
// If reviewer changed steps, output must have the exact same count
var stepsFeedback = feedbacks.FirstOrDefault(f => !f.StepsOk);
if (stepsFeedback != null && result.NextSteps.Count != stepsFeedback.NextSteps.Count)
{
Console.Error.WriteLine($"[AlarmReviewService] Validation failed for {alarmKey}: nextSteps count mismatch " +
$"(expected {stepsFeedback.NextSteps.Count}, got {result.NextSteps.Count}).");
return null;
}
return result;
}
/// <summary>
/// Builds a DiagnosticResponse from the reviewer's direct edits (majority vote per section).
/// Used when AI output is rejected by validation.
/// </summary>
private static DiagnosticResponse BuildReviewerDirectEdit(
DiagnosticResponse original, List<ReviewFeedback> feedbacks)
{
// For explanation: use first reviewer who changed it, else keep original
var expFeedback = feedbacks.FirstOrDefault(f => !f.ExplanationOk);
var explanation = expFeedback != null ? expFeedback.Explanation : original.Explanation;
// For causes: use first reviewer who changed it, else keep original
var causesFeedback = feedbacks.FirstOrDefault(f => !f.CausesOk);
var causes = causesFeedback != null ? causesFeedback.Causes : original.Causes;
// For steps: use first reviewer who changed it, else keep original
var stepsFeedback = feedbacks.FirstOrDefault(f => !f.StepsOk);
var steps = stepsFeedback != null ? stepsFeedback.NextSteps : original.NextSteps;
return new DiagnosticResponse
{
Explanation = explanation,
Causes = causes,
NextSteps = steps,
};
}
// ── AlarmTranslationsChecked.de.json generation ───────────────────────────
private static void RegenerateCheckedFile(AlarmReviewProgress progress)
{
@ -812,52 +1034,23 @@ public static class AlarmReviewService
foreach (var kv in batch.ImprovedEntries)
improved[kv.Key] = kv.Value;
var totalReviewed = progress.Batches.Count(b => b.Synthesized) * BatchSize;
var lastUpdated = DateTime.Now.ToString("yyyy-MM-dd HH:mm");
// Build the JSON dict: alarmKey → {Explanation, Causes, NextSteps}
// For unreviewed alarms fall back to the existing German translation
var output = new Dictionary<string, object>();
foreach (var key in AllAlarmKeys)
{
var entry = improved.TryGetValue(key, out var imp)
? imp
: DiagnosticService.TryGetTranslation(key, "de") ?? AlarmKnowledgeBase.TryGetDiagnosis(key);
if (entry == null) continue;
output[key] = new { entry.Explanation, entry.Causes, entry.NextSteps };
}
var sb = new StringBuilder();
sb.AppendLine("// AUTO-GENERATED by AlarmReviewService — DO NOT EDIT MANUALLY");
sb.AppendLine($"// Progress: {Math.Min(totalReviewed, AllAlarmKeys.Length)} / {AllAlarmKeys.Length} reviewed | Last updated: {lastUpdated}");
sb.AppendLine();
sb.AppendLine("namespace InnovEnergy.App.Backend.Services;");
sb.AppendLine();
sb.AppendLine("public static class AlarmKnowledgeBaseChecked");
sb.AppendLine("{");
sb.AppendLine(" public static readonly IReadOnlyDictionary<string, DiagnosticResponse> SinexcelAlarms =");
sb.AppendLine(" new Dictionary<string, DiagnosticResponse>");
sb.AppendLine(" {");
foreach (var key in SinexcelKeys)
AppendEntry(sb, key, improved);
sb.AppendLine(" };");
sb.AppendLine();
sb.AppendLine(" public static readonly IReadOnlyDictionary<string, DiagnosticResponse> GrowattAlarms =");
sb.AppendLine(" new Dictionary<string, DiagnosticResponse>");
sb.AppendLine(" {");
foreach (var key in GrowattKeys)
AppendEntry(sb, key, improved);
sb.AppendLine(" };");
sb.AppendLine();
// Emit the lookup method so the generated file is self-contained and can fully replace AlarmKnowledgeBase.cs
sb.AppendLine(" public static DiagnosticResponse? TryGetDiagnosis(string alarmDescription)");
sb.AppendLine(" {");
sb.AppendLine(" if (string.IsNullOrWhiteSpace(alarmDescription)) return null;");
sb.AppendLine(" var normalized = alarmDescription.Trim();");
sb.AppendLine(" if (SinexcelAlarms.TryGetValue(normalized, out var s)) return s;");
sb.AppendLine(" if (GrowattAlarms.TryGetValue(normalized, out var g)) return g;");
sb.AppendLine(" var lower = normalized.ToLowerInvariant();");
sb.AppendLine(" foreach (var kvp in SinexcelAlarms)");
sb.AppendLine(" if (kvp.Key.ToLowerInvariant() == lower) return kvp.Value;");
sb.AppendLine(" foreach (var kvp in GrowattAlarms)");
sb.AppendLine(" if (kvp.Key.ToLowerInvariant() == lower) return kvp.Value;");
sb.AppendLine(" return null;");
sb.AppendLine(" }");
sb.AppendLine("}");
File.WriteAllText(CheckedFilePath, sb.ToString());
Console.WriteLine($"[AlarmReviewService] AlarmKnowledgeBaseChecked.cs written ({Math.Min(totalReviewed, AllAlarmKeys.Length)}/{AllAlarmKeys.Length} reviewed).");
var json = JsonConvert.SerializeObject(output, Formatting.Indented);
var totalReviewed = Math.Min(progress.Batches.Count(b => b.Synthesized) * BatchSize, AllAlarmKeys.Length);
Directory.CreateDirectory(ResourcesDir);
File.WriteAllText(CheckedFilePath, json, System.Text.Encoding.UTF8);
Console.WriteLine($"[AlarmReviewService] AlarmTranslationsChecked.de.json written ({totalReviewed}/{AllAlarmKeys.Length} reviewed).");
}
private static void AppendEntry(StringBuilder sb, string key, Dictionary<string, DiagnosticResponse> improved)
@ -1196,7 +1389,7 @@ render();
"Irgendwo da draußen liest ein Kunde Ihre Worte um 2 Uhr nachts. Machen Sie sie beruhigend. 🌟",
"Klarheit ist eine Superkraft. Heute setzen Sie sie ein. 💪",
"Die beste Alarmmeldung ist eine, bei der der Kunde sagt: 'Ach so, das macht Sinn'. 💡",
"Ihre 15 Minuten heute könnten hunderten von Kunden stundenlange Verwirrung ersparen. ⏱️",
"Ihre 10 Minuten heute könnten hunderten von Kunden stundenlange Verwirrung ersparen. ⏱️",
"Einfache Sprache ist schwer. Danke, dass Sie sich die Zeit nehmen, es richtig zu machen. ✍️",
"Hinter jedem Alarmcode steckt ein echter Mensch, der einfach wissen möchte, ob sein Zuhause sicher ist. 🏠",
"Sie übersetzen nicht nur Text — Sie übersetzen Ingenieursprache in Menschensprache. 🌍",
@ -1217,7 +1410,7 @@ render();
private static string BuildReviewerEmailHtml(string name, string reviewUrl, int batchNum, int alarmCount, string quote, bool isResend)
{
var urgentBanner = isResend
? """<div style="background:#fdf3e7;border-left:4px solid #e67e22;padding:12px 16px;border-radius:0 4px 4px 0;margin-bottom:16px;font-size:13px">⚠️ <strong>Noch keine Rückmeldungen eingegangen.</strong> Dieselben Alarme werden erneut gesendet. Bitte bis 8 Uhr morgen früh einreichen.</div>"""
? """<div style="background:#fdf3e7;border-left:4px solid #e67e22;padding:12px 16px;border-radius:0 4px 4px 0;margin-bottom:16px;font-size:13px">⚠️ <strong>Noch keine Rückmeldungen eingegangen.</strong> Dieselben Alarme werden erneut gesendet. Bitte bis 8:00 Uhr morgen früh einreichen.</div>"""
: "";
return $"""
@ -1233,7 +1426,7 @@ render();
<tr><td style="background:#fce8ed;padding:22px 28px">
{urgentBanner}
<p style="margin-bottom:13px">Hallo <strong>{name}</strong>,</p>
<p style="margin-bottom:13px">Bitte überprüfen Sie heute die <strong>{alarmCount} Alarmbeschreibungen</strong> und markieren Sie jede als <em>'Passt so'</em> oder bearbeiten Sie sie, um sie zu verbessern. Dies dauert etwa <strong>1520 Minuten</strong>.</p>
<p style="margin-bottom:13px">Bitte überprüfen Sie heute die <strong>{alarmCount} Alarmbeschreibungen</strong> und markieren Sie jede als <em>'Passt so'</em> oder bearbeiten Sie sie, um sie zu verbessern. Dies dauert etwa <strong>10 Minuten</strong>.</p>
<p style="margin-bottom:18px;font-size:12px;color:#888"> Bitte bis <strong>8:00 Uhr morgen früh</strong> abschließen.</p>
<div style="text-align:center;margin-bottom:22px">
<a href="{reviewUrl}" style="background:#27ae60;color:#fff;text-decoration:none;padding:11px 30px;border-radius:6px;font-size:14px;font-weight:bold;display:inline-block">Überprüfung starten </a>
@ -1269,12 +1462,12 @@ render();
private static async Task SendReminderEmailAsync(BatchRecord batch, string name, string email)
{
var reviewUrl = $"{BaseUrl}/ReviewAlarms?batch={batch.BatchNumber}&reviewer={Uri.EscapeDataString(name)}";
var subject = $"Erinnerung: Alarmprüfung Stapel {batch.BatchNumber} bis 8 Uhr morgen abschließen";
var subject = $"Erinnerung: Alarmprüfung Stapel {batch.BatchNumber} bis 8:00 Uhr morgen früh abschließen";
var html = $"""
<!DOCTYPE html><html><head><meta charset="utf-8"></head>
<body style="font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#333;padding:24px;max-width:500px">
<p>Hallo <strong>{name}</strong>,</p>
<p style="margin-top:12px">Kurze Erinnerung die heutige Alarmprüfung <strong>(Stapel {batch.BatchNumber})</strong> schließt um <strong>8 Uhr morgen früh</strong>. Es dauert nur 15 Minuten!</p>
<p style="margin-top:12px">Kurze Erinnerung die heutige Alarmprüfung <strong>(Stapel {batch.BatchNumber})</strong> schließt um <strong>8:00 Uhr morgen früh</strong>. Es dauert nur 10 Minuten!</p>
<p style="margin:20px 0"><a href="{reviewUrl}" style="background:#3498db;color:#fff;text-decoration:none;padding:10px 24px;border-radius:6px;font-size:13px;display:inline-block">Überprüfung abschließen </a></p>
<p style="font-size:11px;color:#bbb">inesco Energy Monitor</p>
</body></html>
@ -1293,7 +1486,7 @@ render();
var beforeAfterRows = new StringBuilder();
foreach (var key in batch.AlarmKeys)
{
var original = AlarmKnowledgeBase.TryGetDiagnosis(key);
var original = DiagnosticService.TryGetTranslation(key, "de") ?? AlarmKnowledgeBase.TryGetDiagnosis(key);
var improved = batch.ImprovedEntries.TryGetValue(key, out var imp) ? imp : null;
var label = GermanName(key);
var changed = improved != null &&
@ -1302,26 +1495,28 @@ render();
!improved.NextSteps.SequenceEqual(original?.NextSteps ?? Array.Empty<string>()));
var statusColor = changed ? "#e67e22" : "#27ae60";
var statusText = changed ? "Aktualisiert" : "Unverändert";
var statusText = changed ? "KI aktualisiert" : "Unverändert";
var correctUrl = $"{BaseUrl}/CorrectAlarm?batch={batch.BatchNumber}&key={Uri.EscapeDataString(key)}";
beforeAfterRows.Append($"""
<tr><td colspan="2" style="padding:14px 0 4px;font-weight:bold;font-size:14px;color:#2c3e50;border-top:2px solid #eee">
{label} &nbsp;<span style="font-size:11px;font-weight:normal;color:{statusColor};background:#f8f9fa;padding:2px 8px;border-radius:10px">{statusText}</span>
{System.Web.HttpUtility.HtmlEncode(label)}
&nbsp;<span style="font-size:11px;font-weight:normal;color:{statusColor};background:#f8f9fa;padding:2px 8px;border-radius:10px">{statusText}</span>
{(changed ? $"&nbsp;<a href='{correctUrl}' style='font-size:11px;color:#3498db;text-decoration:none;border:1px solid #3498db;border-radius:10px;padding:2px 8px'>✏ Korrigieren</a>" : "")}
</td></tr>
""");
// Explanation
var origExp = original?.Explanation ?? "(none)";
var newExp = improved?.Explanation ?? origExp;
var expStyle = newExp != origExp ? "color:#c0392b;text-decoration:line-through" : "color:#555";
beforeAfterRows.Append($"""
<tr>
<td style="padding:4px 12px 4px 0;vertical-align:top;width:50%;font-size:12px;color:#888;font-style:italic">Vorher</td>
<td style="padding:4px 0;vertical-align:top;width:50%;font-size:12px;color:#888;font-style:italic">Nachher</td>
<td style="padding:4px 12px 4px 0;vertical-align:top;width:50%;font-size:12px;color:#888;font-style:italic">Vorher (Deutsch)</td>
<td style="padding:4px 0;vertical-align:top;width:50%;font-size:12px;color:#888;font-style:italic">Nachher (KI)</td>
</tr>
<tr>
<td style="padding:2px 12px 8px 0;vertical-align:top;font-size:13px;{expStyle}">{System.Web.HttpUtility.HtmlEncode(origExp)}</td>
<td style="padding:2px 0 8px;vertical-align:top;font-size:13px;color:#27ae60">{System.Web.HttpUtility.HtmlEncode(newExp)}</td>
<td style="padding:2px 12px 8px 0;vertical-align:top;font-size:13px;color:#555">{System.Web.HttpUtility.HtmlEncode(origExp)}</td>
<td style="padding:2px 0 8px;vertical-align:top;font-size:13px;color:{(newExp != origExp ? "#e67e22" : "#27ae60")}">{System.Web.HttpUtility.HtmlEncode(newExp)}</td>
</tr>
""");
@ -1336,7 +1531,7 @@ render();
</td>
<td style="padding:2px 0 6px;vertical-align:top;font-size:12px">
<div style="font-size:11px;color:#888;margin-bottom:3px">Ursachen</div>
{string.Join("", newCauses.Select(c => $"<div style='font-size:12px;color:#27ae60;padding-left:8px'>• {System.Web.HttpUtility.HtmlEncode(c)}</div>"))}
{string.Join("", newCauses.Select(c => $"<div style='font-size:12px;color:{(!newCauses.SequenceEqual(origCauses) ? "#e67e22" : "#27ae60")};padding-left:8px'>• {System.Web.HttpUtility.HtmlEncode(c)}</div>"))}
</td>
</tr>
""");
@ -1352,7 +1547,7 @@ render();
</td>
<td style="padding:2px 0 10px;vertical-align:top;font-size:12px">
<div style="font-size:11px;color:#888;margin-bottom:3px">Was zu tun ist</div>
{string.Join("", newSteps.Select(s => $"<div style='font-size:12px;color:#27ae60;padding-left:8px'>• {System.Web.HttpUtility.HtmlEncode(s)}</div>"))}
{string.Join("", newSteps.Select(s => $"<div style='font-size:12px;color:{(!newSteps.SequenceEqual(origSteps) ? "#e67e22" : "#27ae60")};padding-left:8px'>• {System.Web.HttpUtility.HtmlEncode(s)}</div>"))}
</td>
</tr>
""");
@ -1403,14 +1598,13 @@ render();
<body style="font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#333;padding:24px;max-width:560px">
<h2 style="color:#27ae60"> Alarm Review Campaign Complete</h2>
<p style="margin-top:12px">All <strong>229 alarms</strong> have been reviewed and synthesized.</p>
<p style="margin-top:8px"><strong>AlarmKnowledgeBaseChecked.cs</strong> is ready for cutover on the server.</p>
<h3 style="margin-top:22px;color:#2c3e50">Cutover Steps</h3>
<p style="margin-top:8px"><strong>AlarmTranslationsChecked.de.json</strong> is ready on the server at <code>Resources/AlarmTranslationsChecked.de.json</code>.</p>
<h3 style="margin-top:22px;color:#2c3e50">Next Steps</h3>
<ol style="margin-top:10px;line-height:2.2;padding-left:20px">
<li>Download the checked file:<br><code>curl "{BaseUrl}/DownloadCheckedKnowledgeBase?authToken=YOUR_TOKEN" -o AlarmKnowledgeBaseChecked.cs</code></li>
<li>Move it to <code>csharp/App/Backend/Services/</code></li>
<li>Delete <code>AlarmKnowledgeBase.cs</code></li>
<li>Rename class <code>AlarmKnowledgeBaseChecked</code> <code>AlarmKnowledgeBase</code></li>
<li>Run: <code>dotnet build &amp;&amp; ./deploy.sh</code></li>
<li>On your local machine, pull the latest <code>Resources/</code> folder from the server</li>
<li>Run <code>generate_alarm_translations.py</code> reads <code>AlarmTranslationsChecked.de.json</code>, generates <code>en.json</code>, <code>fr.json</code>, <code>it.json</code></li>
<li>Update <code>DiagnosticService</code> to load <code>AlarmTranslations.en.json</code> for English</li>
<li>Deploy: <code>cd csharp/App/Backend &amp;&amp; ./deploy.sh</code></li>
</ol>
<h3 style="margin-top:22px;color:#2c3e50">Campaign Summary</h3>
<table style="border-collapse:collapse;margin-top:8px">
@ -1465,4 +1659,55 @@ render();
private static string EscapeForCSharp(string s) =>
s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\r", "").Replace("\n", "\\n");
// ── Workday check (no emails on weekends or Swiss public holidays) ──────────
private static bool IsWorkday(DateTime date)
{
if (date.DayOfWeek == DayOfWeek.Saturday || date.DayOfWeek == DayOfWeek.Sunday)
return false;
return !IsSwissPublicHoliday(date.Date);
}
private static bool IsSwissPublicHoliday(DateTime date)
{
var year = date.Year;
// Fixed federal holidays
if (date == new DateTime(year, 1, 1)) return true; // New Year's Day
if (date == new DateTime(year, 8, 1)) return true; // Swiss National Day
if (date == new DateTime(year, 12, 25)) return true; // Christmas Day
if (date == new DateTime(year, 12, 26)) return true; // St. Stephen's Day
// Easter-based holidays
var easter = GetEasterSunday(year);
if (date == easter.AddDays(-2)) return true; // Good Friday
if (date == easter) return true; // Easter Sunday
if (date == easter.AddDays(1)) return true; // Easter Monday
if (date == easter.AddDays(39)) return true; // Ascension Day
if (date == easter.AddDays(49)) return true; // Whit Sunday (Pfingstsonntag)
if (date == easter.AddDays(50)) return true; // Whit Monday (Pfingstmontag)
return false;
}
private static DateTime GetEasterSunday(int year)
{
// Anonymous Gregorian algorithm
int a = year % 19;
int b = year / 100;
int c = year % 100;
int d = b / 4;
int e = b % 4;
int f = (b + 8) / 25;
int g = (b - f + 1) / 3;
int h = (19 * a + b - d - g + 15) % 30;
int i = c / 4;
int k = c % 4;
int l = (32 + 2 * e + 2 * i - h - k) % 7;
int m = (a + 11 * h + 22 * l) / 451;
int month = (h + l - 7 * m + 114) / 31;
int day = ((h + l - 7 * m + 114) % 31) + 1;
return new DateTime(year, month, day);
}
}

View File

@ -33,9 +33,10 @@ public static class DiagnosticService
else
_apiKey = apiKey;
// Load pre-generated translation files (de, fr, it) if available
// Load pre-generated translation files (en, de, fr, it) if available
// en.json is generated by generate_alarm_translations.py after the review campaign
var resourcesDir = Path.Combine(AppContext.BaseDirectory, "Resources");
foreach (var lang in new[] { "de", "fr", "it" })
foreach (var lang in new[] { "en", "de", "fr", "it" })
{
var file = Path.Combine(resourcesDir, $"AlarmTranslations.{lang}.json");
if (!File.Exists(file)) continue;
@ -156,6 +157,12 @@ public static class DiagnosticService
/// </summary>
public static DiagnosticResponse? TryGetTranslation(string errorDescription, string language)
{
// Check JSON translations first (en.json exists after review campaign)
if (Translations.TryGetValue(language, out var langDict) &&
langDict.TryGetValue(errorDescription, out var translated))
return translated;
// Fallback: English from compiled AlarmKnowledgeBase.cs (until en.json is deployed)
if (language == "en")
{
var kb = AlarmKnowledgeBase.TryGetDiagnosis(errorDescription);
@ -169,10 +176,6 @@ public static class DiagnosticService
};
}
if (Translations.TryGetValue(language, out var langDict) &&
langDict.TryGetValue(errorDescription, out var translated))
return translated;
return null;
}

View File

@ -2,95 +2,49 @@
"""
generate_alarm_translations.py
One-time script: reads AlarmKnowledgeBase.cs, calls Mistral API to translate
all alarm entries into German (de), French (fr), and Italian (it), and writes:
Post-campaign script: reads AlarmTranslationsChecked.de.json (the reviewed and
AI-synthesized German content), translates into English, French, and Italian,
and writes:
Resources/AlarmTranslations.de.json backend uses these at startup
Resources/AlarmTranslations.fr.json
Resources/AlarmTranslations.it.json
Resources/AlarmNames.de.json frontend lang file additions
Resources/AlarmNames.fr.json
Resources/AlarmNames.it.json
Resources/AlarmTranslations.de.json replace with reviewed German
Resources/AlarmTranslations.en.json back-translated from German
Resources/AlarmTranslations.fr.json translated from German
Resources/AlarmTranslations.it.json translated from German
Services/AlarmKnowledgeBase.cs updated English source (keeps same structure)
Usage:
Run this AFTER the review campaign is complete:
export MISTRAL_API_KEY=your_key_here
cd csharp/App/Backend
python3 generate_alarm_translations.py
Output files can be reviewed/edited before committing.
"""
import re
import json
import os
import re
import sys
import time
import shutil
from typing import Optional
import requests
# ── Config ─────────────────────────────────────────────────────────────────
KNOWLEDGE_BASE_FILE = "Services/AlarmKnowledgeBase.cs"
CHECKED_FILE = "Resources/AlarmTranslationsChecked.de.json"
KNOWLEDGE_BASE = "Services/AlarmKnowledgeBase.cs"
RESOURCES_DIR = "Resources"
MISTRAL_URL = "https://api.mistral.ai/v1/chat/completions"
MISTRAL_MODEL = "mistral-small-latest"
BATCH_SIZE = 3 # alarms per API call — smaller = less chance of token truncation
MISTRAL_MODEL = "mistral-large-latest"
BATCH_SIZE = 5 # alarms per API call
RETRY_DELAY = 5 # seconds between retries on rate-limit
MAX_RETRIES = 3
REQUEST_TIMEOUT = (10, 90) # (connect_timeout, read_timeout) in seconds
REQUEST_TIMEOUT = (10, 90)
LANGUAGES = {
"de": "German",
TARGET_LANGUAGES = {
"en": "English",
"fr": "French",
"it": "Italian",
}
# ── Parsing ─────────────────────────────────────────────────────────────────
def split_camel_case(name: str) -> str:
"""'AbnormalGridVoltage''Abnormal Grid Voltage'"""
return re.sub(r'(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])', ' ', name).strip()
def parse_knowledge_base(filepath: str) -> dict:
"""
Parses AlarmKnowledgeBase.cs and returns a dict:
{ "AlarmKey": { "Explanation": "...", "Causes": [...], "NextSteps": [...] } }
"""
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
alarms = {}
# Find positions of all alarm key declarations: ["Key"] = new()
key_matches = list(re.finditer(r'\["(\w+)"\]\s*=\s*new\(\)', content))
for i, key_match in enumerate(key_matches):
key = key_match.group(1)
start = key_match.start()
end = key_matches[i + 1].start() if i + 1 < len(key_matches) else len(content)
block = content[start:end]
# Explanation (single string)
exp_match = re.search(r'Explanation\s*=\s*"((?:[^"\\]|\\.)*)"', block)
explanation = exp_match.group(1) if exp_match else ""
# Causes (string array)
causes_section = re.search(r'Causes\s*=\s*new\[\]\s*\{([^}]+)\}', block, re.DOTALL)
causes = re.findall(r'"((?:[^"\\]|\\.)*)"', causes_section.group(1)) if causes_section else []
# NextSteps (string array)
steps_section = re.search(r'NextSteps\s*=\s*new\[\]\s*\{([^}]+)\}', block, re.DOTALL)
next_steps = re.findall(r'"((?:[^"\\]|\\.)*)"', steps_section.group(1)) if steps_section else []
if explanation or causes or next_steps:
alarms[key] = {
"Explanation": explanation,
"Causes": causes,
"NextSteps": next_steps,
}
return alarms
# ── Mistral API ─────────────────────────────────────────────────────────────
@ -102,8 +56,8 @@ def call_mistral(api_key: str, prompt: str) -> Optional[str]:
body = {
"model": MISTRAL_MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 1400, # ~3 alarms × ~450 tokens each (German is verbose)
"temperature": 0.1, # low for consistent translations
"max_tokens": 1800,
"temperature": 0.1,
}
for attempt in range(1, MAX_RETRIES + 1):
@ -114,9 +68,7 @@ def call_mistral(api_key: str, prompt: str) -> Optional[str]:
time.sleep(RETRY_DELAY * attempt)
continue
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"].strip()
# Strip markdown code fences if present
content = resp.json()["choices"][0]["message"]["content"].strip()
if content.startswith("```"):
first_newline = content.index("\n")
content = content[first_newline + 1:]
@ -130,35 +82,24 @@ def call_mistral(api_key: str, prompt: str) -> Optional[str]:
return None
def translate_batch(api_key: str, batch: dict, language_name: str) -> Optional[dict]:
def translate_batch(api_key: str, batch: dict, target_language: str) -> Optional[dict]:
"""
Translates a batch of alarms into the target language.
Returns dict with same keys + translated content including a localized Name.
Translates a batch of German alarm entries into the target language.
Input: { "AlarmKey": { "Explanation": "...", "Causes": [...], "NextSteps": [...] } }
Output: same structure in target language.
"""
# Build input JSON (only English content, no need to send back keys)
input_data = {}
for key, entry in batch.items():
english_name = split_camel_case(key)
input_data[key] = {
"EnglishName": english_name,
"Explanation": entry["Explanation"],
"Causes": entry["Causes"],
"NextSteps": entry["NextSteps"],
}
prompt = f"""You are translating battery energy storage system alarm descriptions from German into {target_language}.
The source content has been reviewed by field engineers and is accurate.
Translate faithfully keep the same number of bullet points, same meaning, plain language for homeowners.
prompt = f"""You are translating battery energy storage system alarm descriptions into {language_name}.
Translate each alarm entry. The "Name" should be a short (2-5 word) localized display title for the alarm.
Keep technical terms accurate but use plain language a homeowner would understand.
Input JSON (German):
{json.dumps(batch, ensure_ascii=False, indent=2)}
Input JSON:
{json.dumps(input_data, ensure_ascii=False, indent=2)}
Return ONLY a valid JSON object with the same alarm keys. Each value must have exactly these fields:
Return ONLY a valid JSON object with the same alarm keys. Each value must have exactly:
{{
"Name": "short {language_name} title",
"Explanation": "translated explanation sentence",
"Causes": ["translated cause 1", "translated cause 2"],
"NextSteps": ["translated step 1", "translated step 2"]
"Explanation": "translated explanation (1 sentence)",
"Causes": ["translated cause 1", ...],
"NextSteps": ["translated step 1", ...]
}}
Reply with ONLY the JSON object, no markdown, no extra text."""
@ -168,18 +109,112 @@ Reply with ONLY the JSON object, no markdown, no extra text."""
return None
try:
result = json.loads(raw)
return result
return json.loads(raw)
except json.JSONDecodeError as e:
print(f" JSON parse error: {e}")
print(f" Raw response (first 300 chars): {raw[:300]}")
print(f" Raw (first 300 chars): {raw[:300]}")
return None
# ── AlarmKnowledgeBase.cs generation ────────────────────────────────────────
def parse_kb_key_sections(filepath: str) -> dict:
"""
Reads AlarmKnowledgeBase.cs and returns {key: "Sinexcel"|"Growatt"}
preserving the original section order.
"""
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
sinexcel_match = re.search(r'SinexcelAlarms\s*=\s*new Dictionary.*?\{(.*?)^\s*\};', content, re.DOTALL | re.MULTILINE)
growatt_match = re.search(r'GrowattAlarms\s*=\s*new Dictionary.*?\{(.*?)^\s*\};', content, re.DOTALL | re.MULTILINE)
result = {}
if sinexcel_match:
for key in re.findall(r'\["(\w+)"\]\s*=\s*new\(\)', sinexcel_match.group(1)):
result[key] = "Sinexcel"
if growatt_match:
for key in re.findall(r'\["(\w+)"\]\s*=\s*new\(\)', growatt_match.group(1)):
result[key] = "Growatt"
return result
def cs_escape(s: str) -> str:
"""Escapes a string for use inside a C# double-quoted string literal."""
return s.replace("\\", "\\\\").replace('"', '\\"')
def write_knowledge_base_cs(filepath: str, en_translations: dict, key_sections: dict):
"""
Writes an updated AlarmKnowledgeBase.cs using the new English translations,
preserving the original Sinexcel/Growatt section structure.
"""
sinexcel_keys = [k for k, s in key_sections.items() if s == "Sinexcel"]
growatt_keys = [k for k, s in key_sections.items() if s == "Growatt"]
def entry_block(key: str) -> str:
entry = en_translations.get(key)
if not entry:
return f' // [{key}] — no translation available\n'
exp = cs_escape(entry.get("Explanation", ""))
causes = ",\n ".join(f'"{cs_escape(c)}"' for c in entry.get("Causes", []))
steps = ",\n ".join(f'"{cs_escape(s)}"' for s in entry.get("NextSteps", []))
return (
f' ["{key}"] = new()\n'
f' {{\n'
f' Explanation = "{exp}",\n'
f' Causes = new[] {{ {causes} }},\n'
f' NextSteps = new[] {{ {steps} }}\n'
f' }},\n'
)
lines = []
lines.append("namespace InnovEnergy.App.Backend.Services;\n")
lines.append("\n")
lines.append("/// <summary>\n")
lines.append("/// Static knowledge base for Sinexcel and Growatt alarms.\n")
lines.append("/// Provides pre-defined diagnostics without requiring Mistral API calls.\n")
lines.append("/// Updated by generate_alarm_translations.py after the review campaign.\n")
lines.append("/// </summary>\n")
lines.append("public static class AlarmKnowledgeBase\n")
lines.append("{\n")
lines.append(" public static DiagnosticResponse? TryGetDiagnosis(string alarmDescription)\n")
lines.append(" {\n")
lines.append(" if (string.IsNullOrWhiteSpace(alarmDescription)) return null;\n")
lines.append(" var normalized = alarmDescription.Trim();\n")
lines.append(" if (SinexcelAlarms.TryGetValue(normalized, out var s)) return s;\n")
lines.append(" if (GrowattAlarms.TryGetValue(normalized, out var g)) return g;\n")
lines.append(" var lower = normalized.ToLowerInvariant();\n")
lines.append(" foreach (var kvp in SinexcelAlarms) if (kvp.Key.ToLowerInvariant() == lower) return kvp.Value;\n")
lines.append(" foreach (var kvp in GrowattAlarms) if (kvp.Key.ToLowerInvariant() == lower) return kvp.Value;\n")
lines.append(" return null;\n")
lines.append(" }\n")
lines.append("\n")
lines.append(" // ── Sinexcel Alarms ──────────────────────────────────────────────────────\n")
lines.append("\n")
lines.append(" private static readonly IReadOnlyDictionary<string, DiagnosticResponse> SinexcelAlarms = new Dictionary<string, DiagnosticResponse>\n")
lines.append(" {\n")
for key in sinexcel_keys:
lines.append(entry_block(key))
lines.append(" };\n")
lines.append("\n")
lines.append(" // ── Growatt Alarms ───────────────────────────────────────────────────────\n")
lines.append("\n")
lines.append(" private static readonly IReadOnlyDictionary<string, DiagnosticResponse> GrowattAlarms = new Dictionary<string, DiagnosticResponse>\n")
lines.append(" {\n")
for key in growatt_keys:
lines.append(entry_block(key))
lines.append(" };\n")
lines.append("}\n")
with open(filepath, "w", encoding="utf-8") as f:
f.writelines(lines)
print(f" ✓ Wrote updated AlarmKnowledgeBase.cs ({len(sinexcel_keys)} Sinexcel + {len(growatt_keys)} Growatt keys)")
# ── Main ────────────────────────────────────────────────────────────────────
def load_env_file(env_path: str) -> dict:
"""Parse a simple KEY=VALUE .env file."""
env = {}
try:
with open(env_path) as f:
@ -194,12 +229,10 @@ def load_env_file(env_path: str) -> dict:
def main():
# Try environment variable first, then .env file in the same directory
api_key = os.environ.get("MISTRAL_API_KEY", "").strip()
if not api_key:
script_dir = os.path.dirname(os.path.abspath(__file__))
env_vars = load_env_file(os.path.join(script_dir, ".env"))
api_key = env_vars.get("MISTRAL_API_KEY", "").strip()
api_key = load_env_file(os.path.join(script_dir, ".env")).get("MISTRAL_API_KEY", "").strip()
if not api_key:
print("ERROR: MISTRAL_API_KEY not found in environment or .env file.")
@ -207,29 +240,32 @@ def main():
print("MISTRAL_API_KEY loaded.")
# Parse knowledge base
print(f"Parsing {KNOWLEDGE_BASE_FILE}...")
alarms = parse_knowledge_base(KNOWLEDGE_BASE_FILE)
print(f" Found {len(alarms)} alarm entries.")
if not alarms:
print("ERROR: No alarms parsed. Check the file path and format.")
# Load reviewed German source
if not os.path.exists(CHECKED_FILE):
print(f"ERROR: {CHECKED_FILE} not found. Run the review campaign first.")
sys.exit(1)
alarm_keys = list(alarms.keys())
os.makedirs(RESOURCES_DIR, exist_ok=True)
with open(CHECKED_FILE, "r", encoding="utf-8") as f:
german_source = json.load(f)
# Process each language
for lang_code, lang_name in LANGUAGES.items():
alarm_keys = list(german_source.keys())
print(f"Loaded {len(alarm_keys)} alarms from {CHECKED_FILE}.")
# Step 1: copy reviewed German as the new de.json
de_out = os.path.join(RESOURCES_DIR, "AlarmTranslations.de.json")
shutil.copy(CHECKED_FILE, de_out)
print(f"\n✓ Copied reviewed German → {de_out}")
# Step 2: translate to en, fr, it
all_translations = {} # lang_code → {key → entry}
for lang_code, lang_name in TARGET_LANGUAGES.items():
print(f"\n── Translating to {lang_name} ({lang_code}) ──")
translations = {} # key → {Name, Explanation, Causes, NextSteps}
alarm_name_keys = {} # "alarm_Key" → translated name (for lang JSON files)
translations = {}
failed_keys = []
# Split into batches
batches = [
{k: alarms[k] for k in alarm_keys[i:i + BATCH_SIZE]}
{k: german_source[k] for k in alarm_keys[i:i + BATCH_SIZE]}
for i in range(0, len(alarm_keys), BATCH_SIZE)
]
@ -240,7 +276,7 @@ def main():
result = translate_batch(api_key, batch, lang_name)
if result is None:
print(f" FAILED batch {batch_num}will mark keys as failed")
print(f" FAILED batch {batch_num}marking keys as failed")
failed_keys.extend(keys_in_batch)
continue
@ -252,32 +288,32 @@ def main():
"Causes": entry.get("Causes", []),
"NextSteps": entry.get("NextSteps", []),
}
alarm_name_keys[f"alarm_{key}"] = entry.get("Name", split_camel_case(key))
else:
print(f" WARNING: key '{key}' missing from batch result")
failed_keys.append(key)
# Small pause between batches to avoid rate limits
if batch_num < len(batches):
time.sleep(1)
# Write backend translation file
backend_file = os.path.join(RESOURCES_DIR, f"AlarmTranslations.{lang_code}.json")
with open(backend_file, "w", encoding="utf-8") as f:
all_translations[lang_code] = translations
out_file = os.path.join(RESOURCES_DIR, f"AlarmTranslations.{lang_code}.json")
with open(out_file, "w", encoding="utf-8") as f:
json.dump(translations, f, ensure_ascii=False, indent=2)
print(f" Wrote {len(translations)} entries → {backend_file}")
# Write frontend alarm name file (to be merged into lang JSON)
names_file = os.path.join(RESOURCES_DIR, f"AlarmNames.{lang_code}.json")
with open(names_file, "w", encoding="utf-8") as f:
json.dump(alarm_name_keys, f, ensure_ascii=False, indent=2)
print(f" Wrote {len(alarm_name_keys)} name keys → {names_file}")
print(f" ✓ Wrote {len(translations)} entries → {out_file}")
if failed_keys:
print(f" FAILED keys ({len(failed_keys)}): {failed_keys}")
print(f" ⚠ Failed keys ({len(failed_keys)}): {failed_keys}")
print("\n✓ Done. Review the output files in Resources/ before committing.")
print(" Next: merge AlarmNames.*.json entries into src/lang/de.json, fr.json, it.json")
# Step 3: update AlarmKnowledgeBase.cs with the new English back-translation
print("\n── Updating AlarmKnowledgeBase.cs ──")
if "en" in all_translations and os.path.exists(KNOWLEDGE_BASE):
key_sections = parse_kb_key_sections(KNOWLEDGE_BASE)
write_knowledge_base_cs(KNOWLEDGE_BASE, all_translations["en"], key_sections)
else:
print(" Skipped — en.json not generated or AlarmKnowledgeBase.cs not found.")
print("\n✓ Done. Review the output files before deploying.")
print(" Next: cd csharp/App/Backend && dotnet build && ./deploy.sh")
if __name__ == "__main__":