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." }); 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))] [HttpGet(nameof(ReviewAlarms))]
public ActionResult ReviewAlarms(int batch, string reviewer) public ActionResult ReviewAlarms(int batch, string reviewer)
{ {

View File

@ -27,6 +27,7 @@ public class BatchRecord
[JsonProperty("synthesizedAt")] public string? SynthesizedAt { get; set; } [JsonProperty("synthesizedAt")] public string? SynthesizedAt { get; set; }
[JsonProperty("submissions")] public Dictionary<string, ReviewerSubmission?> Submissions { get; set; } = new(); [JsonProperty("submissions")] public Dictionary<string, ReviewerSubmission?> Submissions { get; set; } = new();
[JsonProperty("improvedEntries")] public Dictionary<string, DiagnosticResponse> ImprovedEntries{ get; set; } = new(); [JsonProperty("improvedEntries")] public Dictionary<string, DiagnosticResponse> ImprovedEntries{ get; set; } = new();
[JsonProperty("note")] public string? Note { get; set; }
} }
public class ReviewerSubmission public class ReviewerSubmission
@ -46,6 +47,15 @@ public class ReviewFeedback
[JsonProperty("comment")] public string Comment { get; set; } = ""; [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 ─────────────────────────────────────────────────────────────────── // ── Service ───────────────────────────────────────────────────────────────────
public static class AlarmReviewService public static class AlarmReviewService
@ -68,7 +78,7 @@ public static class AlarmReviewService
private static string ResourcesDir => Path.Combine(AppContext.BaseDirectory, "Resources"); private static string ResourcesDir => Path.Combine(AppContext.BaseDirectory, "Resources");
private static string ProgressFile => Path.Combine(ResourcesDir, "alarm-review-progress.json"); 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) ──────────── // ── German alarm display names (loaded from AlarmNames.de.json) ────────────
@ -228,6 +238,11 @@ public static class AlarmReviewService
private static async Task RunMorningJobAsync() 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..."); Console.WriteLine("[AlarmReviewService] Running 8AM morning job...");
var progress = LoadProgress(); var progress = LoadProgress();
if (progress == null) return; if (progress == null) return;
@ -237,7 +252,14 @@ public static class AlarmReviewService
if (current.Synthesized) 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 else
{ {
@ -245,22 +267,49 @@ public static class AlarmReviewService
if (submissionCount == 0) if (submissionCount == 0)
{ {
current.ResendCount++; const int MaxResends = 3;
SaveProgress(progress); if (current.ResendCount >= MaxResends)
Console.WriteLine($"[AlarmReviewService] Batch {current.BatchNumber}: 0 submissions — resending (attempt #{current.ResendCount})."); {
await SendBatchEmailsAsync(current, isResend: true); // No responses after 3 resends — auto-advance using original content
await SendAdminStallAlertAsync(current); 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 else
{ {
// SynthesizeBatchAsync will call SendNextBatchAsync internally when done
await SynthesizeBatchAsync(current, progress); await SynthesizeBatchAsync(current, progress);
await SendNextBatchAsync(progress);
} }
} }
} }
private static async Task SendNextBatchAsync(AlarmReviewProgress 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; var nextStartIndex = progress.Batches.Count * BatchSize;
if (nextStartIndex >= AllAlarmKeys.Length) if (nextStartIndex >= AllAlarmKeys.Length)
{ {
@ -283,6 +332,11 @@ public static class AlarmReviewService
private static async Task RunAfternoonJobAsync() 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..."); Console.WriteLine("[AlarmReviewService] Running 2PM afternoon job...");
var progress = LoadProgress(); var progress = LoadProgress();
if (progress == null) return; if (progress == null) return;
@ -509,7 +563,7 @@ public static class AlarmReviewService
// Run all synthesis calls in parallel // Run all synthesis calls in parallel
var synthTasks = testBatch.AlarmKeys.Select(async (key, i) => 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 fb = feedbacks[i];
var anyChanges = !fb.ExplanationOk || !fb.CausesOk || !fb.StepsOk; 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() public static string? GetCheckedFileContent()
{ {
if (!File.Exists(CheckedFilePath)) return null; if (!File.Exists(CheckedFilePath)) return null;
@ -653,7 +796,7 @@ public static class AlarmReviewService
for (int i = 0; i < batch.AlarmKeys.Count; i++) for (int i = 0; i < batch.AlarmKeys.Count; i++)
{ {
var alarmKey = batch.AlarmKeys[i]; var alarmKey = batch.AlarmKeys[i];
var original = AlarmKnowledgeBase.TryGetDiagnosis(alarmKey); var original = DiagnosticService.TryGetTranslation(alarmKey, "de") ?? AlarmKnowledgeBase.TryGetDiagnosis(alarmKey);
if (original == null) continue; if (original == null) continue;
var feedbacks = submissions var feedbacks = submissions
@ -663,17 +806,17 @@ public static class AlarmReviewService
var anyChanges = feedbacks.Any(f => !f.ExplanationOk || !f.CausesOk || !f.StepsOk); var anyChanges = feedbacks.Any(f => !f.ExplanationOk || !f.CausesOk || !f.StepsOk);
DiagnosticResponse? improved = null; if (!anyChanges)
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
{ {
Explanation = original.Explanation, batch.ImprovedEntries[alarmKey] = original;
Causes = original.Causes, continue;
NextSteps = original.NextSteps, }
};
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; batch.Synthesized = true;
@ -691,6 +834,9 @@ public static class AlarmReviewService
var totalReviewed = progress.Batches.Count(b => b.Synthesized) * BatchSize; var totalReviewed = progress.Batches.Count(b => b.Synthesized) * BatchSize;
await SendAdminDailySummaryAsync(batch, Math.Min(totalReviewed, AllAlarmKeys.Length)); await SendAdminDailySummaryAsync(batch, Math.Min(totalReviewed, AllAlarmKeys.Length));
// Send next batch immediately after synthesis — no need to wait for 8AM
await SendNextBatchAsync(progress);
} }
finally finally
{ {
@ -715,10 +861,10 @@ public static class AlarmReviewService
{ {
var requestBody = new var requestBody = new
{ {
model = "mistral-small-latest", model = "mistral-large-latest",
messages = new[] { new { role = "user", content = prompt } }, messages = new[] { new { role = "user", content = prompt } },
max_tokens = 500, max_tokens = 600,
temperature = 0.2, temperature = 0.1,
}; };
var responseText = await MistralUrl var responseText = await MistralUrl
@ -739,7 +885,8 @@ public static class AlarmReviewService
json = json.Trim(); json = json.Trim();
} }
return JsonConvert.DeserializeObject<DiagnosticResponse>(json); var result = JsonConvert.DeserializeObject<DiagnosticResponse>(json);
return ValidateSynthesisOutput(result, feedbacks, alarmKey);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -788,13 +935,14 @@ public static class AlarmReviewService
sb.AppendLine(); sb.AppendLine();
sb.AppendLine("SYNTHESIS RULES:"); sb.AppendLine("SYNTHESIS RULES:");
sb.AppendLine("- Use reviewer changes where provided (they have direct support experience)"); sb.AppendLine("- The original content is already in German. Output must also be in German.");
sb.AppendLine("- If multiple reviewers changed the same section, synthesize the best version"); sb.AppendLine("- Use simple plain language suitable for homeowners, NOT technical jargon.");
sb.AppendLine("- Language target: German (Deutsch), simple plain language for homeowners, NOT technical jargon"); sb.AppendLine("- For each section (Explanation / Causes / Next Steps):");
sb.AppendLine("- Output MUST be in German regardless of the original content language"); sb.AppendLine(" * If reviewer marked it 'Approved as-is': keep the original text exactly, no changes.");
sb.AppendLine("- Explanation: exactly 1 sentence, max 25 words"); sb.AppendLine(" * If reviewer provided a changed list: output EXACTLY those items — same count, same meaning.");
sb.AppendLine("- Causes: 24 bullets, plain language"); sb.AppendLine(" DO NOT add, remove, merge, or invent any bullets. Only improve the German phrasing.");
sb.AppendLine("- Next Steps: 24 action items, easiest/most accessible check first"); 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();
sb.AppendLine("Reply with ONLY valid JSON, no markdown fences:"); sb.AppendLine("Reply with ONLY valid JSON, no markdown fences:");
sb.AppendLine("{\"explanation\":\"...\",\"causes\":[\"...\"],\"nextSteps\":[\"...\"]}"); sb.AppendLine("{\"explanation\":\"...\",\"causes\":[\"...\"],\"nextSteps\":[\"...\"]}");
@ -802,7 +950,81 @@ public static class AlarmReviewService
return sb.ToString(); 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) private static void RegenerateCheckedFile(AlarmReviewProgress progress)
{ {
@ -812,52 +1034,23 @@ public static class AlarmReviewService
foreach (var kv in batch.ImprovedEntries) foreach (var kv in batch.ImprovedEntries)
improved[kv.Key] = kv.Value; improved[kv.Key] = kv.Value;
var totalReviewed = progress.Batches.Count(b => b.Synthesized) * BatchSize; // Build the JSON dict: alarmKey → {Explanation, Causes, NextSteps}
var lastUpdated = DateTime.Now.ToString("yyyy-MM-dd HH:mm"); // 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(); var json = JsonConvert.SerializeObject(output, Formatting.Indented);
sb.AppendLine("// AUTO-GENERATED by AlarmReviewService — DO NOT EDIT MANUALLY"); var totalReviewed = Math.Min(progress.Batches.Count(b => b.Synthesized) * BatchSize, AllAlarmKeys.Length);
sb.AppendLine($"// Progress: {Math.Min(totalReviewed, AllAlarmKeys.Length)} / {AllAlarmKeys.Length} reviewed | Last updated: {lastUpdated}"); Directory.CreateDirectory(ResourcesDir);
sb.AppendLine(); File.WriteAllText(CheckedFilePath, json, System.Text.Encoding.UTF8);
sb.AppendLine("namespace InnovEnergy.App.Backend.Services;"); Console.WriteLine($"[AlarmReviewService] AlarmTranslationsChecked.de.json written ({totalReviewed}/{AllAlarmKeys.Length} reviewed).");
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).");
} }
private static void AppendEntry(StringBuilder sb, string key, Dictionary<string, DiagnosticResponse> improved) 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. 🌟", "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. 💪", "Klarheit ist eine Superkraft. Heute setzen Sie sie ein. 💪",
"Die beste Alarmmeldung ist eine, bei der der Kunde sagt: 'Ach so, das macht Sinn'. 💡", "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. ✍️", "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. 🏠", "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. 🌍", "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) private static string BuildReviewerEmailHtml(string name, string reviewUrl, int batchNum, int alarmCount, string quote, bool isResend)
{ {
var urgentBanner = 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 $""" return $"""
@ -1233,7 +1426,7 @@ render();
<tr><td style="background:#fce8ed;padding:22px 28px"> <tr><td style="background:#fce8ed;padding:22px 28px">
{urgentBanner} {urgentBanner}
<p style="margin-bottom:13px">Hallo <strong>{name}</strong>,</p> <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> <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"> <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> <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) private static async Task SendReminderEmailAsync(BatchRecord batch, string name, string email)
{ {
var reviewUrl = $"{BaseUrl}/ReviewAlarms?batch={batch.BatchNumber}&reviewer={Uri.EscapeDataString(name)}"; 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 = $""" var html = $"""
<!DOCTYPE html><html><head><meta charset="utf-8"></head> <!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"> <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>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="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> <p style="font-size:11px;color:#bbb">inesco Energy Monitor</p>
</body></html> </body></html>
@ -1293,7 +1486,7 @@ render();
var beforeAfterRows = new StringBuilder(); var beforeAfterRows = new StringBuilder();
foreach (var key in batch.AlarmKeys) 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 improved = batch.ImprovedEntries.TryGetValue(key, out var imp) ? imp : null;
var label = GermanName(key); var label = GermanName(key);
var changed = improved != null && var changed = improved != null &&
@ -1301,27 +1494,29 @@ render();
!improved.Causes.SequenceEqual(original?.Causes ?? Array.Empty<string>()) || !improved.Causes.SequenceEqual(original?.Causes ?? Array.Empty<string>()) ||
!improved.NextSteps.SequenceEqual(original?.NextSteps ?? Array.Empty<string>())); !improved.NextSteps.SequenceEqual(original?.NextSteps ?? Array.Empty<string>()));
var statusColor = changed ? "#e67e22" : "#27ae60"; 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($""" beforeAfterRows.Append($"""
<tr><td colspan="2" style="padding:14px 0 4px;font-weight:bold;font-size:14px;color:#2c3e50;border-top:2px solid #eee"> <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> </td></tr>
"""); """);
// Explanation // Explanation
var origExp = original?.Explanation ?? "(none)"; var origExp = original?.Explanation ?? "(none)";
var newExp = improved?.Explanation ?? origExp; var newExp = improved?.Explanation ?? origExp;
var expStyle = newExp != origExp ? "color:#c0392b;text-decoration:line-through" : "color:#555";
beforeAfterRows.Append($""" beforeAfterRows.Append($"""
<tr> <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 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</td> <td style="padding:4px 0;vertical-align:top;width:50%;font-size:12px;color:#888;font-style:italic">Nachher (KI)</td>
</tr> </tr>
<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 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:#27ae60">{System.Web.HttpUtility.HtmlEncode(newExp)}</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> </tr>
"""); """);
@ -1336,7 +1531,7 @@ render();
</td> </td>
<td style="padding:2px 0 6px;vertical-align:top;font-size:12px"> <td style="padding:2px 0 6px;vertical-align:top;font-size:12px">
<div style="font-size:11px;color:#888;margin-bottom:3px">Ursachen</div> <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> </td>
</tr> </tr>
"""); """);
@ -1352,7 +1547,7 @@ render();
</td> </td>
<td style="padding:2px 0 10px;vertical-align:top;font-size:12px"> <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> <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> </td>
</tr> </tr>
"""); """);
@ -1403,14 +1598,13 @@ render();
<body style="font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#333;padding:24px;max-width:560px"> <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> <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: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> <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">Cutover Steps</h3> <h3 style="margin-top:22px;color:#2c3e50">Next Steps</h3>
<ol style="margin-top:10px;line-height:2.2;padding-left:20px"> <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>On your local machine, pull the latest <code>Resources/</code> folder from the server</li>
<li>Move it to <code>csharp/App/Backend/Services/</code></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>Delete <code>AlarmKnowledgeBase.cs</code></li> <li>Update <code>DiagnosticService</code> to load <code>AlarmTranslations.en.json</code> for English</li>
<li>Rename class <code>AlarmKnowledgeBaseChecked</code> <code>AlarmKnowledgeBase</code></li> <li>Deploy: <code>cd csharp/App/Backend &amp;&amp; ./deploy.sh</code></li>
<li>Run: <code>dotnet build &amp;&amp; ./deploy.sh</code></li>
</ol> </ol>
<h3 style="margin-top:22px;color:#2c3e50">Campaign Summary</h3> <h3 style="margin-top:22px;color:#2c3e50">Campaign Summary</h3>
<table style="border-collapse:collapse;margin-top:8px"> <table style="border-collapse:collapse;margin-top:8px">
@ -1465,4 +1659,55 @@ render();
private static string EscapeForCSharp(string s) => private static string EscapeForCSharp(string s) =>
s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\r", "").Replace("\n", "\\n"); 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 else
_apiKey = apiKey; _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"); 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"); var file = Path.Combine(resourcesDir, $"AlarmTranslations.{lang}.json");
if (!File.Exists(file)) continue; if (!File.Exists(file)) continue;
@ -156,6 +157,12 @@ public static class DiagnosticService
/// </summary> /// </summary>
public static DiagnosticResponse? TryGetTranslation(string errorDescription, string language) 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") if (language == "en")
{ {
var kb = AlarmKnowledgeBase.TryGetDiagnosis(errorDescription); 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; return null;
} }

View File

@ -2,95 +2,49 @@
""" """
generate_alarm_translations.py generate_alarm_translations.py
One-time script: reads AlarmKnowledgeBase.cs, calls Mistral API to translate Post-campaign script: reads AlarmTranslationsChecked.de.json (the reviewed and
all alarm entries into German (de), French (fr), and Italian (it), and writes: AI-synthesized German content), translates into English, French, and Italian,
and writes:
Resources/AlarmTranslations.de.json backend uses these at startup Resources/AlarmTranslations.de.json replace with reviewed German
Resources/AlarmTranslations.fr.json Resources/AlarmTranslations.en.json back-translated from German
Resources/AlarmTranslations.it.json Resources/AlarmTranslations.fr.json translated from German
Resources/AlarmNames.de.json frontend lang file additions Resources/AlarmTranslations.it.json translated from German
Resources/AlarmNames.fr.json Services/AlarmKnowledgeBase.cs updated English source (keeps same structure)
Resources/AlarmNames.it.json
Usage: Run this AFTER the review campaign is complete:
export MISTRAL_API_KEY=your_key_here export MISTRAL_API_KEY=your_key_here
cd csharp/App/Backend
python3 generate_alarm_translations.py python3 generate_alarm_translations.py
Output files can be reviewed/edited before committing.
""" """
import re
import json import json
import os import os
import re
import sys import sys
import time import time
import shutil
from typing import Optional from typing import Optional
import requests import requests
# ── Config ───────────────────────────────────────────────────────────────── # ── Config ─────────────────────────────────────────────────────────────────
KNOWLEDGE_BASE_FILE = "Services/AlarmKnowledgeBase.cs" CHECKED_FILE = "Resources/AlarmTranslationsChecked.de.json"
RESOURCES_DIR = "Resources" KNOWLEDGE_BASE = "Services/AlarmKnowledgeBase.cs"
MISTRAL_URL = "https://api.mistral.ai/v1/chat/completions" RESOURCES_DIR = "Resources"
MISTRAL_MODEL = "mistral-small-latest" MISTRAL_URL = "https://api.mistral.ai/v1/chat/completions"
BATCH_SIZE = 3 # alarms per API call — smaller = less chance of token truncation MISTRAL_MODEL = "mistral-large-latest"
RETRY_DELAY = 5 # seconds between retries on rate-limit BATCH_SIZE = 5 # alarms per API call
MAX_RETRIES = 3 RETRY_DELAY = 5 # seconds between retries on rate-limit
REQUEST_TIMEOUT = (10, 90) # (connect_timeout, read_timeout) in seconds MAX_RETRIES = 3
REQUEST_TIMEOUT = (10, 90)
LANGUAGES = { TARGET_LANGUAGES = {
"de": "German", "en": "English",
"fr": "French", "fr": "French",
"it": "Italian", "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 ───────────────────────────────────────────────────────────── # ── Mistral API ─────────────────────────────────────────────────────────────
@ -102,8 +56,8 @@ def call_mistral(api_key: str, prompt: str) -> Optional[str]:
body = { body = {
"model": MISTRAL_MODEL, "model": MISTRAL_MODEL,
"messages": [{"role": "user", "content": prompt}], "messages": [{"role": "user", "content": prompt}],
"max_tokens": 1400, # ~3 alarms × ~450 tokens each (German is verbose) "max_tokens": 1800,
"temperature": 0.1, # low for consistent translations "temperature": 0.1,
} }
for attempt in range(1, MAX_RETRIES + 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) time.sleep(RETRY_DELAY * attempt)
continue continue
resp.raise_for_status() resp.raise_for_status()
data = resp.json() content = resp.json()["choices"][0]["message"]["content"].strip()
content = data["choices"][0]["message"]["content"].strip()
# Strip markdown code fences if present
if content.startswith("```"): if content.startswith("```"):
first_newline = content.index("\n") first_newline = content.index("\n")
content = content[first_newline + 1:] content = content[first_newline + 1:]
@ -130,35 +82,24 @@ def call_mistral(api_key: str, prompt: str) -> Optional[str]:
return None 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. Translates a batch of German alarm entries into the target language.
Returns dict with same keys + translated content including a localized Name. Input: { "AlarmKey": { "Explanation": "...", "Causes": [...], "NextSteps": [...] } }
Output: same structure in target language.
""" """
# Build input JSON (only English content, no need to send back keys) prompt = f"""You are translating battery energy storage system alarm descriptions from German into {target_language}.
input_data = {} The source content has been reviewed by field engineers and is accurate.
for key, entry in batch.items(): Translate faithfully keep the same number of bullet points, same meaning, plain language for homeowners.
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 into {language_name}. Input JSON (German):
Translate each alarm entry. The "Name" should be a short (2-5 word) localized display title for the alarm. {json.dumps(batch, ensure_ascii=False, indent=2)}
Keep technical terms accurate but use plain language a homeowner would understand.
Input JSON: Return ONLY a valid JSON object with the same alarm keys. Each value must have exactly:
{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:
{{ {{
"Name": "short {language_name} title", "Explanation": "translated explanation (1 sentence)",
"Explanation": "translated explanation sentence", "Causes": ["translated cause 1", ...],
"Causes": ["translated cause 1", "translated cause 2"], "NextSteps": ["translated step 1", ...]
"NextSteps": ["translated step 1", "translated step 2"]
}} }}
Reply with ONLY the JSON object, no markdown, no extra text.""" 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 return None
try: try:
result = json.loads(raw) return json.loads(raw)
return result
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
print(f" JSON parse error: {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 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 ──────────────────────────────────────────────────────────────────── # ── Main ────────────────────────────────────────────────────────────────────
def load_env_file(env_path: str) -> dict: def load_env_file(env_path: str) -> dict:
"""Parse a simple KEY=VALUE .env file."""
env = {} env = {}
try: try:
with open(env_path) as f: with open(env_path) as f:
@ -194,12 +229,10 @@ def load_env_file(env_path: str) -> dict:
def main(): def main():
# Try environment variable first, then .env file in the same directory
api_key = os.environ.get("MISTRAL_API_KEY", "").strip() api_key = os.environ.get("MISTRAL_API_KEY", "").strip()
if not api_key: if not api_key:
script_dir = os.path.dirname(os.path.abspath(__file__)) script_dir = os.path.dirname(os.path.abspath(__file__))
env_vars = load_env_file(os.path.join(script_dir, ".env")) api_key = load_env_file(os.path.join(script_dir, ".env")).get("MISTRAL_API_KEY", "").strip()
api_key = env_vars.get("MISTRAL_API_KEY", "").strip()
if not api_key: if not api_key:
print("ERROR: MISTRAL_API_KEY not found in environment or .env file.") print("ERROR: MISTRAL_API_KEY not found in environment or .env file.")
@ -207,29 +240,32 @@ def main():
print("MISTRAL_API_KEY loaded.") print("MISTRAL_API_KEY loaded.")
# Parse knowledge base # Load reviewed German source
print(f"Parsing {KNOWLEDGE_BASE_FILE}...") if not os.path.exists(CHECKED_FILE):
alarms = parse_knowledge_base(KNOWLEDGE_BASE_FILE) print(f"ERROR: {CHECKED_FILE} not found. Run the review campaign first.")
print(f" Found {len(alarms)} alarm entries.")
if not alarms:
print("ERROR: No alarms parsed. Check the file path and format.")
sys.exit(1) sys.exit(1)
alarm_keys = list(alarms.keys()) with open(CHECKED_FILE, "r", encoding="utf-8") as f:
os.makedirs(RESOURCES_DIR, exist_ok=True) german_source = json.load(f)
# Process each language alarm_keys = list(german_source.keys())
for lang_code, lang_name in LANGUAGES.items(): 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}) ──") print(f"\n── Translating to {lang_name} ({lang_code}) ──")
translations = {} # key → {Name, Explanation, Causes, NextSteps} translations = {}
alarm_name_keys = {} # "alarm_Key" → translated name (for lang JSON files) failed_keys = []
failed_keys = []
# Split into batches
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) for i in range(0, len(alarm_keys), BATCH_SIZE)
] ]
@ -240,7 +276,7 @@ def main():
result = translate_batch(api_key, batch, lang_name) result = translate_batch(api_key, batch, lang_name)
if result is None: 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) failed_keys.extend(keys_in_batch)
continue continue
@ -252,32 +288,32 @@ def main():
"Causes": entry.get("Causes", []), "Causes": entry.get("Causes", []),
"NextSteps": entry.get("NextSteps", []), "NextSteps": entry.get("NextSteps", []),
} }
alarm_name_keys[f"alarm_{key}"] = entry.get("Name", split_camel_case(key))
else: else:
print(f" WARNING: key '{key}' missing from batch result") print(f" WARNING: key '{key}' missing from batch result")
failed_keys.append(key) failed_keys.append(key)
# Small pause between batches to avoid rate limits
if batch_num < len(batches): if batch_num < len(batches):
time.sleep(1) time.sleep(1)
# Write backend translation file all_translations[lang_code] = translations
backend_file = os.path.join(RESOURCES_DIR, f"AlarmTranslations.{lang_code}.json") out_file = os.path.join(RESOURCES_DIR, f"AlarmTranslations.{lang_code}.json")
with open(backend_file, "w", encoding="utf-8") as f: with open(out_file, "w", encoding="utf-8") as f:
json.dump(translations, f, ensure_ascii=False, indent=2) json.dump(translations, f, ensure_ascii=False, indent=2)
print(f" Wrote {len(translations)} entries → {backend_file}") print(f" ✓ Wrote {len(translations)} entries → {out_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}")
if failed_keys: 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.") # Step 3: update AlarmKnowledgeBase.cs with the new English back-translation
print(" Next: merge AlarmNames.*.json entries into src/lang/de.json, fr.json, it.json") 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__": if __name__ == "__main__":