From e72f16f26b098e89f48c7ad21f92ba6662be276c Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Wed, 25 Feb 2026 20:08:40 +0100 Subject: [PATCH] improve alarm diagnosis review service --- csharp/App/Backend/Controller.cs | 21 + .../Backend/Services/AlarmReviewService.cs | 447 ++++++++++++++---- .../App/Backend/Services/DiagnosticService.cs | 15 +- ...enerate_alarm_translations.cpython-310.pyc | Bin 8104 -> 11697 bytes .../Backend/generate_alarm_translations.py | 310 ++++++------ 5 files changed, 549 insertions(+), 244 deletions(-) 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 7eeb344ad607bfe8f28f554626ed91daeed09137..6f54ae44f3324d0787ed9da3c0648a05c14d8b49 100644 GIT binary patch literal 11697 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 literal 8104 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 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__":