improve alarm diagnosis review service
This commit is contained in:
parent
8de43276a0
commit
e72f16f26b
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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+')">✕</button></div>';
|
||||||
|
}).join('')+'<button class="btn-add" onclick="add(\''+type+'\')">+ Hinzufügen</button>';
|
||||||
|
}
|
||||||
|
function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||||||
|
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: 2–4 bullets, plain language");
|
sb.AppendLine(" DO NOT add, remove, merge, or invent any bullets. Only improve the German phrasing.");
|
||||||
sb.AppendLine("- Next Steps: 2–4 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>15–20 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} <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)}
|
||||||
|
<span style="font-size:11px;font-weight:normal;color:{statusColor};background:#f8f9fa;padding:2px 8px;border-radius:10px">{statusText}</span>
|
||||||
|
{(changed ? $" <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 && ./deploy.sh</code></li>
|
||||||
<li>Run: <code>dotnet build && ./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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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__":
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue