diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index 4d7ecb7e4..afe5b474b 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -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(), + NextSteps = req.NextSteps ?? new List(), + }; + 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) { diff --git a/csharp/App/Backend/Services/AlarmReviewService.cs b/csharp/App/Backend/Services/AlarmReviewService.cs index 71c34f923..f66fdcd96 100644 --- a/csharp/App/Backend/Services/AlarmReviewService.cs +++ b/csharp/App/Backend/Services/AlarmReviewService.cs @@ -27,6 +27,7 @@ public class BatchRecord [JsonProperty("synthesizedAt")] public string? SynthesizedAt { get; set; } [JsonProperty("submissions")] public Dictionary Submissions { get; set; } = new(); [JsonProperty("improvedEntries")] public Dictionary 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? Causes { get; set; } + [JsonProperty("nextSteps")] public List? 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,7 +252,14 @@ public static class AlarmReviewService if (current.Synthesized) { - await SendNextBatchAsync(progress); + // 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 { @@ -245,22 +267,49 @@ public static class AlarmReviewService if (submissionCount == 0) { - current.ResendCount++; - SaveProgress(progress); - Console.WriteLine($"[AlarmReviewService] Batch {current.BatchNumber}: 0 submissions — resending (attempt #{current.ResendCount})."); - await SendBatchEmailsAsync(current, isResend: true); - await SendAdminStallAlertAsync(current); + 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); + 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 }; } - /// Returns the generated AlarmKnowledgeBaseChecked.cs content for download. + /// Returns an HTML correction form pre-filled with the current synthesized content for a specific alarm. + 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 "

Alarm not found.

"; + + 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 $$""" + + Korrektur — {{System.Web.HttpUtility.HtmlEncode(label)}} + + +
Alarm-Korrektur
{{System.Web.HttpUtility.HtmlEncode(label)}}
+ + + +
+ +
+ +
+ + """; + } + + /// Applies an admin correction to a specific alarm entry and regenerates the checked file. + 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; + } + + /// Returns the generated AlarmTranslationsChecked.de.json content for download. 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(json); + var result = JsonConvert.DeserializeObject(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: 2–4 bullets, plain language"); - sb.AppendLine("- Next Steps: 2–4 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 ──────────────────────────────── + /// + /// 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. + /// + private static DiagnosticResponse? ValidateSynthesisOutput( + DiagnosticResponse? result, List 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; + } + + /// + /// Builds a DiagnosticResponse from the reviewer's direct edits (majority vote per section). + /// Used when AI output is rejected by validation. + /// + private static DiagnosticResponse BuildReviewerDirectEdit( + DiagnosticResponse original, List 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(); + 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 SinexcelAlarms ="); - sb.AppendLine(" new Dictionary"); - sb.AppendLine(" {"); - foreach (var key in SinexcelKeys) - AppendEntry(sb, key, improved); - sb.AppendLine(" };"); - sb.AppendLine(); - - sb.AppendLine(" public static readonly IReadOnlyDictionary GrowattAlarms ="); - sb.AppendLine(" new Dictionary"); - 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 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 - ? """
⚠️ Noch keine Rückmeldungen eingegangen. Dieselben Alarme werden erneut gesendet. Bitte bis 8 Uhr morgen früh einreichen.
""" + ? """
⚠️ Noch keine Rückmeldungen eingegangen. Dieselben Alarme werden erneut gesendet. Bitte bis 8:00 Uhr morgen früh einreichen.
""" : ""; return $""" @@ -1233,7 +1426,7 @@ render(); {urgentBanner}

Hallo {name},

-

Bitte überprüfen Sie heute die {alarmCount} Alarmbeschreibungen und markieren Sie jede als 'Passt so' oder bearbeiten Sie sie, um sie zu verbessern. Dies dauert etwa 15–20 Minuten.

+

Bitte überprüfen Sie heute die {alarmCount} Alarmbeschreibungen und markieren Sie jede als 'Passt so' oder bearbeiten Sie sie, um sie zu verbessern. Dies dauert etwa 10 Minuten.

⏰ Bitte bis 8:00 Uhr morgen früh abschließen.

Überprüfung starten → @@ -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 = $"""

Hallo {name},

-

Kurze Erinnerung — die heutige Alarmprüfung (Stapel {batch.BatchNumber}) schließt um 8 Uhr morgen früh. Es dauert nur 15 Minuten!

+

Kurze Erinnerung — die heutige Alarmprüfung (Stapel {batch.BatchNumber}) schließt um 8:00 Uhr morgen früh. Es dauert nur 10 Minuten!

Überprüfung abschließen →

inesco Energy Monitor

@@ -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 && @@ -1301,27 +1494,29 @@ render(); !improved.Causes.SequenceEqual(original?.Causes ?? Array.Empty()) || !improved.NextSteps.SequenceEqual(original?.NextSteps ?? Array.Empty())); - var statusColor = changed ? "#e67e22" : "#27ae60"; - var statusText = changed ? "Aktualisiert" : "Unverändert"; + var statusColor = changed ? "#e67e22" : "#27ae60"; + var statusText = changed ? "KI aktualisiert" : "Unverändert"; + var correctUrl = $"{BaseUrl}/CorrectAlarm?batch={batch.BatchNumber}&key={Uri.EscapeDataString(key)}"; beforeAfterRows.Append($""" - {label}  {statusText} + {System.Web.HttpUtility.HtmlEncode(label)} +  {statusText} + {(changed ? $" ✏ Korrigieren" : "")} """); // 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($""" - Vorher - Nachher + Vorher (Deutsch) + Nachher (KI) - {System.Web.HttpUtility.HtmlEncode(origExp)} - {System.Web.HttpUtility.HtmlEncode(newExp)} + {System.Web.HttpUtility.HtmlEncode(origExp)} + {System.Web.HttpUtility.HtmlEncode(newExp)} """); @@ -1336,7 +1531,7 @@ render();
Ursachen
- {string.Join("", newCauses.Select(c => $"
• {System.Web.HttpUtility.HtmlEncode(c)}
"))} + {string.Join("", newCauses.Select(c => $"
• {System.Web.HttpUtility.HtmlEncode(c)}
"))} """); @@ -1352,7 +1547,7 @@ render();
Was zu tun ist
- {string.Join("", newSteps.Select(s => $"
• {System.Web.HttpUtility.HtmlEncode(s)}
"))} + {string.Join("", newSteps.Select(s => $"
• {System.Web.HttpUtility.HtmlEncode(s)}
"))} """); @@ -1403,14 +1598,13 @@ render();

✅ Alarm Review Campaign Complete

All 229 alarms have been reviewed and synthesized.

-

AlarmKnowledgeBaseChecked.cs is ready for cutover on the server.

-

Cutover Steps

+

AlarmTranslationsChecked.de.json is ready on the server at Resources/AlarmTranslationsChecked.de.json.

+

Next Steps

    -
  1. Download the checked file:
    curl "{BaseUrl}/DownloadCheckedKnowledgeBase?authToken=YOUR_TOKEN" -o AlarmKnowledgeBaseChecked.cs
  2. -
  3. Move it to csharp/App/Backend/Services/
  4. -
  5. Delete AlarmKnowledgeBase.cs
  6. -
  7. Rename class AlarmKnowledgeBaseCheckedAlarmKnowledgeBase
  8. -
  9. Run: dotnet build && ./deploy.sh
  10. +
  11. On your local machine, pull the latest Resources/ folder from the server
  12. +
  13. Run generate_alarm_translations.py — reads AlarmTranslationsChecked.de.json, generates en.json, fr.json, it.json
  14. +
  15. Update DiagnosticService to load AlarmTranslations.en.json for English
  16. +
  17. Deploy: cd csharp/App/Backend && ./deploy.sh

Campaign Summary

@@ -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); + } } diff --git a/csharp/App/Backend/Services/DiagnosticService.cs b/csharp/App/Backend/Services/DiagnosticService.cs index dc972398a..9fc6911ca 100644 --- a/csharp/App/Backend/Services/DiagnosticService.cs +++ b/csharp/App/Backend/Services/DiagnosticService.cs @@ -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 /// 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; } diff --git a/csharp/App/Backend/__pycache__/generate_alarm_translations.cpython-310.pyc b/csharp/App/Backend/__pycache__/generate_alarm_translations.cpython-310.pyc index 7eeb344ad..6f54ae44f 100644 Binary files a/csharp/App/Backend/__pycache__/generate_alarm_translations.cpython-310.pyc and b/csharp/App/Backend/__pycache__/generate_alarm_translations.cpython-310.pyc differ diff --git a/csharp/App/Backend/generate_alarm_translations.py b/csharp/App/Backend/generate_alarm_translations.py index 199342bbe..f5537bd96 100644 --- a/csharp/App/Backend/generate_alarm_translations.py +++ b/csharp/App/Backend/generate_alarm_translations.py @@ -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" -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 -RETRY_DELAY = 5 # seconds between retries on rate-limit -MAX_RETRIES = 3 -REQUEST_TIMEOUT = (10, 90) # (connect_timeout, read_timeout) in seconds +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-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) -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("/// \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("/// \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 SinexcelAlarms = new Dictionary\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 GrowattAlarms = new Dictionary\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) - failed_keys = [] + 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__":