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." });
|
||||
}
|
||||
|
||||
[HttpGet(nameof(CorrectAlarm))]
|
||||
public ActionResult CorrectAlarm(int batch, string key)
|
||||
{
|
||||
var html = AlarmReviewService.GetCorrectionPage(batch, key);
|
||||
return Content(html, "text/html");
|
||||
}
|
||||
|
||||
[HttpPost(nameof(ApplyAlarmCorrection))]
|
||||
public ActionResult ApplyAlarmCorrection([FromBody] AlarmCorrectionRequest req)
|
||||
{
|
||||
if (req == null) return BadRequest();
|
||||
var correction = new DiagnosticResponse
|
||||
{
|
||||
Explanation = req.Explanation ?? "",
|
||||
Causes = req.Causes ?? new List<string>(),
|
||||
NextSteps = req.NextSteps ?? new List<string>(),
|
||||
};
|
||||
var ok = AlarmReviewService.ApplyCorrection(req.BatchNumber, req.AlarmKey ?? "", correction);
|
||||
return ok ? Ok(new { message = "Korrektur gespeichert." }) : BadRequest("Batch or alarm not found.");
|
||||
}
|
||||
|
||||
[HttpGet(nameof(ReviewAlarms))]
|
||||
public ActionResult ReviewAlarms(int batch, string reviewer)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ public class BatchRecord
|
|||
[JsonProperty("synthesizedAt")] public string? SynthesizedAt { get; set; }
|
||||
[JsonProperty("submissions")] public Dictionary<string, ReviewerSubmission?> Submissions { get; set; } = new();
|
||||
[JsonProperty("improvedEntries")] public Dictionary<string, DiagnosticResponse> ImprovedEntries{ get; set; } = new();
|
||||
[JsonProperty("note")] public string? Note { get; set; }
|
||||
}
|
||||
|
||||
public class ReviewerSubmission
|
||||
|
|
@ -46,6 +47,15 @@ public class ReviewFeedback
|
|||
[JsonProperty("comment")] public string Comment { get; set; } = "";
|
||||
}
|
||||
|
||||
public class AlarmCorrectionRequest
|
||||
{
|
||||
[JsonProperty("batchNumber")] public int BatchNumber { get; set; }
|
||||
[JsonProperty("alarmKey")] public string? AlarmKey { get; set; }
|
||||
[JsonProperty("explanation")] public string? Explanation { get; set; }
|
||||
[JsonProperty("causes")] public List<string>? Causes { get; set; }
|
||||
[JsonProperty("nextSteps")] public List<string>? NextSteps { get; set; }
|
||||
}
|
||||
|
||||
// ── Service ───────────────────────────────────────────────────────────────────
|
||||
|
||||
public static class AlarmReviewService
|
||||
|
|
@ -68,7 +78,7 @@ public static class AlarmReviewService
|
|||
|
||||
private static string ResourcesDir => Path.Combine(AppContext.BaseDirectory, "Resources");
|
||||
private static string ProgressFile => Path.Combine(ResourcesDir, "alarm-review-progress.json");
|
||||
private static string CheckedFilePath => Path.Combine(AppContext.BaseDirectory, "AlarmKnowledgeBaseChecked.cs");
|
||||
private static string CheckedFilePath => Path.Combine(ResourcesDir, "AlarmTranslationsChecked.de.json");
|
||||
|
||||
// ── German alarm display names (loaded from AlarmNames.de.json) ────────────
|
||||
|
||||
|
|
@ -228,6 +238,11 @@ public static class AlarmReviewService
|
|||
|
||||
private static async Task RunMorningJobAsync()
|
||||
{
|
||||
if (!IsWorkday(DateTime.Now))
|
||||
{
|
||||
Console.WriteLine("[AlarmReviewService] 8AM job skipped — weekend or public holiday.");
|
||||
return;
|
||||
}
|
||||
Console.WriteLine("[AlarmReviewService] Running 8AM morning job...");
|
||||
var progress = LoadProgress();
|
||||
if (progress == null) return;
|
||||
|
|
@ -237,30 +252,64 @@ public static class AlarmReviewService
|
|||
|
||||
if (current.Synthesized)
|
||||
{
|
||||
// Next batch is sent immediately after synthesis — only act here as a safety net
|
||||
// in case the server restarted before SendNextBatchAsync could run.
|
||||
var nextAlreadySent = progress.Batches.Count > current.BatchNumber;
|
||||
if (!nextAlreadySent)
|
||||
{
|
||||
Console.WriteLine($"[AlarmReviewService] Batch {current.BatchNumber} was synthesized but next batch not sent — sending now (recovery).");
|
||||
await SendNextBatchAsync(progress);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var submissionCount = current.Submissions.Values.Count(s => s != null);
|
||||
|
||||
if (submissionCount == 0)
|
||||
{
|
||||
current.ResendCount++;
|
||||
const int MaxResends = 3;
|
||||
if (current.ResendCount >= MaxResends)
|
||||
{
|
||||
// No responses after 3 resends — auto-advance using original content
|
||||
Console.WriteLine($"[AlarmReviewService] Batch {current.BatchNumber}: still 0 submissions after {MaxResends} resends — auto-advancing with original content.");
|
||||
foreach (var key in current.AlarmKeys)
|
||||
{
|
||||
var original = DiagnosticService.TryGetTranslation(key, "de") ?? AlarmKnowledgeBase.TryGetDiagnosis(key);
|
||||
if (original != null) current.ImprovedEntries[key] = original;
|
||||
}
|
||||
current.Synthesized = true;
|
||||
current.SynthesizedAt = DateTime.UtcNow.ToString("O");
|
||||
current.Note = "Auto-advanced after 3 resends with no reviewer responses — original content preserved.";
|
||||
SaveProgress(progress);
|
||||
Console.WriteLine($"[AlarmReviewService] Batch {current.BatchNumber}: 0 submissions — resending (attempt #{current.ResendCount}).");
|
||||
await SendBatchEmailsAsync(current, isResend: true);
|
||||
RegenerateCheckedFile(progress);
|
||||
await SendAdminStallAlertAsync(current);
|
||||
await SendNextBatchAsync(progress);
|
||||
}
|
||||
else
|
||||
{
|
||||
current.ResendCount++;
|
||||
SaveProgress(progress);
|
||||
Console.WriteLine($"[AlarmReviewService] Batch {current.BatchNumber}: 0 submissions — resending (attempt #{current.ResendCount}/{MaxResends}).");
|
||||
await SendBatchEmailsAsync(current, isResend: true);
|
||||
await SendAdminStallAlertAsync(current);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// SynthesizeBatchAsync will call SendNextBatchAsync internally when done
|
||||
await SynthesizeBatchAsync(current, progress);
|
||||
await SendNextBatchAsync(progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SendNextBatchAsync(AlarmReviewProgress progress)
|
||||
{
|
||||
if (!IsWorkday(DateTime.Now))
|
||||
{
|
||||
Console.WriteLine("[AlarmReviewService] Synthesis complete but today is not a workday — next batch will be sent on the next working day.");
|
||||
return; // Morning job recovery will send the next batch on the next workday
|
||||
}
|
||||
|
||||
var nextStartIndex = progress.Batches.Count * BatchSize;
|
||||
if (nextStartIndex >= AllAlarmKeys.Length)
|
||||
{
|
||||
|
|
@ -283,6 +332,11 @@ public static class AlarmReviewService
|
|||
|
||||
private static async Task RunAfternoonJobAsync()
|
||||
{
|
||||
if (!IsWorkday(DateTime.Now))
|
||||
{
|
||||
Console.WriteLine("[AlarmReviewService] 2PM job skipped — weekend or public holiday.");
|
||||
return;
|
||||
}
|
||||
Console.WriteLine("[AlarmReviewService] Running 2PM afternoon job...");
|
||||
var progress = LoadProgress();
|
||||
if (progress == null) return;
|
||||
|
|
@ -509,7 +563,7 @@ public static class AlarmReviewService
|
|||
// Run all synthesis calls in parallel
|
||||
var synthTasks = testBatch.AlarmKeys.Select(async (key, i) =>
|
||||
{
|
||||
var original = AlarmKnowledgeBase.TryGetDiagnosis(key);
|
||||
var original = DiagnosticService.TryGetTranslation(key, "de") ?? AlarmKnowledgeBase.TryGetDiagnosis(key);
|
||||
var fb = feedbacks[i];
|
||||
var anyChanges = !fb.ExplanationOk || !fb.CausesOk || !fb.StepsOk;
|
||||
|
||||
|
|
@ -625,7 +679,96 @@ public static class AlarmReviewService
|
|||
};
|
||||
}
|
||||
|
||||
/// <summary>Returns the generated AlarmKnowledgeBaseChecked.cs content for download.</summary>
|
||||
/// <summary>Returns an HTML correction form pre-filled with the current synthesized content for a specific alarm.</summary>
|
||||
public static string GetCorrectionPage(int batchNumber, string alarmKey)
|
||||
{
|
||||
var progress = LoadProgress();
|
||||
var batch = progress?.Batches.FirstOrDefault(b => b.BatchNumber == batchNumber);
|
||||
if (batch == null || !batch.ImprovedEntries.TryGetValue(alarmKey, out var entry))
|
||||
return "<html><body><h3>Alarm not found.</h3></body></html>";
|
||||
|
||||
var label = GermanName(alarmKey);
|
||||
var causesJson = Newtonsoft.Json.JsonConvert.SerializeObject(entry.Causes);
|
||||
var stepsJson = Newtonsoft.Json.JsonConvert.SerializeObject(entry.NextSteps);
|
||||
var submitUrl = $"{BaseUrl}/ApplyAlarmCorrection";
|
||||
|
||||
return $$"""
|
||||
<!DOCTYPE html><html lang="de">
|
||||
<head><meta charset="utf-8"><title>Korrektur — {{System.Web.HttpUtility.HtmlEncode(label)}}</title>
|
||||
<style>
|
||||
body{font-family:Arial,sans-serif;max-width:640px;margin:32px auto;padding:0 16px;color:#333}
|
||||
h2{color:#2c3e50}label{font-weight:bold;font-size:13px;display:block;margin:14px 0 4px}
|
||||
textarea,input{width:100%;box-sizing:border-box;border:1px solid #ddd;border-radius:4px;padding:8px;font-size:13px;font-family:inherit}
|
||||
textarea{resize:vertical}.li-row{display:flex;gap:6px;margin-bottom:5px}
|
||||
.li-row textarea{flex:1;min-height:34px}.btn-rm{background:#e74c3c;color:#fff;border:none;border-radius:4px;width:26px;cursor:pointer}
|
||||
.btn-add{background:#3498db;color:#fff;border:none;border-radius:4px;padding:5px 12px;cursor:pointer;font-size:12px;margin-top:4px}
|
||||
.btn-save{background:#27ae60;color:#fff;border:none;border-radius:6px;padding:11px 28px;font-size:15px;cursor:pointer;margin-top:20px;font-weight:bold}
|
||||
.hdr{background:#2c3e50;color:#fff;padding:16px 20px;border-radius:8px;margin-bottom:24px}
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="hdr"><div style="font-size:16px;font-weight:bold">Alarm-Korrektur</div><div style="font-size:13px;opacity:.8;margin-top:4px">{{System.Web.HttpUtility.HtmlEncode(label)}}</div></div>
|
||||
<label>Erklärung</label>
|
||||
<textarea id="exp" rows="3">{{System.Web.HttpUtility.HtmlEncode(entry.Explanation)}}</textarea>
|
||||
<label>Mögliche Ursachen</label>
|
||||
<div id="causes-ed"></div>
|
||||
<label>Was zu tun ist</label>
|
||||
<div id="steps-ed"></div>
|
||||
<button class="btn-save" onclick="save()">Korrektur speichern</button>
|
||||
<div id="msg" style="margin-top:14px;font-size:13px"></div>
|
||||
<script>
|
||||
var causes = {{causesJson}};
|
||||
var steps = {{stepsJson}};
|
||||
function renderList(type){
|
||||
var arr = type==='causes'?causes:steps;
|
||||
var ed = document.getElementById(type+'-ed');
|
||||
ed.innerHTML = arr.map(function(v,i){
|
||||
return '<div class="li-row"><textarea onchange="upd(\''+type+'\','+i+',this.value)">'+esc(v)+'</textarea>'+
|
||||
'<button class="btn-rm" onclick="rm(\''+type+'\','+i+')">✕</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()
|
||||
{
|
||||
if (!File.Exists(CheckedFilePath)) return null;
|
||||
|
|
@ -653,7 +796,7 @@ public static class AlarmReviewService
|
|||
for (int i = 0; i < batch.AlarmKeys.Count; i++)
|
||||
{
|
||||
var alarmKey = batch.AlarmKeys[i];
|
||||
var original = AlarmKnowledgeBase.TryGetDiagnosis(alarmKey);
|
||||
var original = DiagnosticService.TryGetTranslation(alarmKey, "de") ?? AlarmKnowledgeBase.TryGetDiagnosis(alarmKey);
|
||||
if (original == null) continue;
|
||||
|
||||
var feedbacks = submissions
|
||||
|
|
@ -663,17 +806,17 @@ public static class AlarmReviewService
|
|||
|
||||
var anyChanges = feedbacks.Any(f => !f.ExplanationOk || !f.CausesOk || !f.StepsOk);
|
||||
|
||||
DiagnosticResponse? improved = null;
|
||||
if (anyChanges)
|
||||
improved = await CallMistralForSynthesisAsync(alarmKey, original, feedbacks);
|
||||
|
||||
// Fall back to original if Mistral returned nothing or no changes needed
|
||||
batch.ImprovedEntries[alarmKey] = improved ?? new DiagnosticResponse
|
||||
if (!anyChanges)
|
||||
{
|
||||
Explanation = original.Explanation,
|
||||
Causes = original.Causes,
|
||||
NextSteps = original.NextSteps,
|
||||
};
|
||||
batch.ImprovedEntries[alarmKey] = original;
|
||||
continue;
|
||||
}
|
||||
|
||||
var improved = await CallMistralForSynthesisAsync(alarmKey, original, feedbacks);
|
||||
|
||||
// If AI output is rejected by validation, fall back to reviewer's direct edit
|
||||
// (not the original) so reviewer changes are always respected.
|
||||
batch.ImprovedEntries[alarmKey] = improved ?? BuildReviewerDirectEdit(original, feedbacks);
|
||||
}
|
||||
|
||||
batch.Synthesized = true;
|
||||
|
|
@ -691,6 +834,9 @@ public static class AlarmReviewService
|
|||
|
||||
var totalReviewed = progress.Batches.Count(b => b.Synthesized) * BatchSize;
|
||||
await SendAdminDailySummaryAsync(batch, Math.Min(totalReviewed, AllAlarmKeys.Length));
|
||||
|
||||
// Send next batch immediately after synthesis — no need to wait for 8AM
|
||||
await SendNextBatchAsync(progress);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -715,10 +861,10 @@ public static class AlarmReviewService
|
|||
{
|
||||
var requestBody = new
|
||||
{
|
||||
model = "mistral-small-latest",
|
||||
model = "mistral-large-latest",
|
||||
messages = new[] { new { role = "user", content = prompt } },
|
||||
max_tokens = 500,
|
||||
temperature = 0.2,
|
||||
max_tokens = 600,
|
||||
temperature = 0.1,
|
||||
};
|
||||
|
||||
var responseText = await MistralUrl
|
||||
|
|
@ -739,7 +885,8 @@ public static class AlarmReviewService
|
|||
json = json.Trim();
|
||||
}
|
||||
|
||||
return JsonConvert.DeserializeObject<DiagnosticResponse>(json);
|
||||
var result = JsonConvert.DeserializeObject<DiagnosticResponse>(json);
|
||||
return ValidateSynthesisOutput(result, feedbacks, alarmKey);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
@ -788,13 +935,14 @@ public static class AlarmReviewService
|
|||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("SYNTHESIS RULES:");
|
||||
sb.AppendLine("- Use reviewer changes where provided (they have direct support experience)");
|
||||
sb.AppendLine("- If multiple reviewers changed the same section, synthesize the best version");
|
||||
sb.AppendLine("- Language target: German (Deutsch), simple plain language for homeowners, NOT technical jargon");
|
||||
sb.AppendLine("- Output MUST be in German regardless of the original content language");
|
||||
sb.AppendLine("- Explanation: exactly 1 sentence, max 25 words");
|
||||
sb.AppendLine("- Causes: 2–4 bullets, plain language");
|
||||
sb.AppendLine("- Next Steps: 2–4 action items, easiest/most accessible check first");
|
||||
sb.AppendLine("- The original content is already in German. Output must also be in German.");
|
||||
sb.AppendLine("- Use simple plain language suitable for homeowners, NOT technical jargon.");
|
||||
sb.AppendLine("- For each section (Explanation / Causes / Next Steps):");
|
||||
sb.AppendLine(" * If reviewer marked it 'Approved as-is': keep the original text exactly, no changes.");
|
||||
sb.AppendLine(" * If reviewer provided a changed list: output EXACTLY those items — same count, same meaning.");
|
||||
sb.AppendLine(" DO NOT add, remove, merge, or invent any bullets. Only improve the German phrasing.");
|
||||
sb.AppendLine("- If multiple reviewers changed the same section, pick the best version or merge carefully.");
|
||||
sb.AppendLine("- Explanation: 1 sentence, max 25 words.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Reply with ONLY valid JSON, no markdown fences:");
|
||||
sb.AppendLine("{\"explanation\":\"...\",\"causes\":[\"...\"],\"nextSteps\":[\"...\"]}");
|
||||
|
|
@ -802,7 +950,81 @@ public static class AlarmReviewService
|
|||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ── AlarmKnowledgeBaseChecked.cs generation ────────────────────────────────
|
||||
/// <summary>
|
||||
/// Validates AI synthesis output. Returns null (reject) if output is structurally bad
|
||||
/// so the caller can fall back to the reviewer's direct edit instead.
|
||||
/// </summary>
|
||||
private static DiagnosticResponse? ValidateSynthesisOutput(
|
||||
DiagnosticResponse? result, List<ReviewFeedback> feedbacks, string alarmKey)
|
||||
{
|
||||
if (result == null) return null;
|
||||
|
||||
// Must have non-empty content
|
||||
if (string.IsNullOrWhiteSpace(result.Explanation))
|
||||
{
|
||||
Console.Error.WriteLine($"[AlarmReviewService] Validation failed for {alarmKey}: empty explanation.");
|
||||
return null;
|
||||
}
|
||||
if (result.Causes == null || result.Causes.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine($"[AlarmReviewService] Validation failed for {alarmKey}: empty causes.");
|
||||
return null;
|
||||
}
|
||||
if (result.NextSteps == null || result.NextSteps.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine($"[AlarmReviewService] Validation failed for {alarmKey}: empty nextSteps.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// If reviewer changed causes, output must have the exact same count
|
||||
var causesFeedback = feedbacks.FirstOrDefault(f => !f.CausesOk);
|
||||
if (causesFeedback != null && result.Causes.Count != causesFeedback.Causes.Count)
|
||||
{
|
||||
Console.Error.WriteLine($"[AlarmReviewService] Validation failed for {alarmKey}: causes count mismatch " +
|
||||
$"(expected {causesFeedback.Causes.Count}, got {result.Causes.Count}).");
|
||||
return null;
|
||||
}
|
||||
|
||||
// If reviewer changed steps, output must have the exact same count
|
||||
var stepsFeedback = feedbacks.FirstOrDefault(f => !f.StepsOk);
|
||||
if (stepsFeedback != null && result.NextSteps.Count != stepsFeedback.NextSteps.Count)
|
||||
{
|
||||
Console.Error.WriteLine($"[AlarmReviewService] Validation failed for {alarmKey}: nextSteps count mismatch " +
|
||||
$"(expected {stepsFeedback.NextSteps.Count}, got {result.NextSteps.Count}).");
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a DiagnosticResponse from the reviewer's direct edits (majority vote per section).
|
||||
/// Used when AI output is rejected by validation.
|
||||
/// </summary>
|
||||
private static DiagnosticResponse BuildReviewerDirectEdit(
|
||||
DiagnosticResponse original, List<ReviewFeedback> feedbacks)
|
||||
{
|
||||
// For explanation: use first reviewer who changed it, else keep original
|
||||
var expFeedback = feedbacks.FirstOrDefault(f => !f.ExplanationOk);
|
||||
var explanation = expFeedback != null ? expFeedback.Explanation : original.Explanation;
|
||||
|
||||
// For causes: use first reviewer who changed it, else keep original
|
||||
var causesFeedback = feedbacks.FirstOrDefault(f => !f.CausesOk);
|
||||
var causes = causesFeedback != null ? causesFeedback.Causes : original.Causes;
|
||||
|
||||
// For steps: use first reviewer who changed it, else keep original
|
||||
var stepsFeedback = feedbacks.FirstOrDefault(f => !f.StepsOk);
|
||||
var steps = stepsFeedback != null ? stepsFeedback.NextSteps : original.NextSteps;
|
||||
|
||||
return new DiagnosticResponse
|
||||
{
|
||||
Explanation = explanation,
|
||||
Causes = causes,
|
||||
NextSteps = steps,
|
||||
};
|
||||
}
|
||||
|
||||
// ── AlarmTranslationsChecked.de.json generation ───────────────────────────
|
||||
|
||||
private static void RegenerateCheckedFile(AlarmReviewProgress progress)
|
||||
{
|
||||
|
|
@ -812,52 +1034,23 @@ public static class AlarmReviewService
|
|||
foreach (var kv in batch.ImprovedEntries)
|
||||
improved[kv.Key] = kv.Value;
|
||||
|
||||
var totalReviewed = progress.Batches.Count(b => b.Synthesized) * BatchSize;
|
||||
var lastUpdated = DateTime.Now.ToString("yyyy-MM-dd HH:mm");
|
||||
// Build the JSON dict: alarmKey → {Explanation, Causes, NextSteps}
|
||||
// For unreviewed alarms fall back to the existing German translation
|
||||
var output = new Dictionary<string, object>();
|
||||
foreach (var key in AllAlarmKeys)
|
||||
{
|
||||
var entry = improved.TryGetValue(key, out var imp)
|
||||
? imp
|
||||
: DiagnosticService.TryGetTranslation(key, "de") ?? AlarmKnowledgeBase.TryGetDiagnosis(key);
|
||||
if (entry == null) continue;
|
||||
output[key] = new { entry.Explanation, entry.Causes, entry.NextSteps };
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("// AUTO-GENERATED by AlarmReviewService — DO NOT EDIT MANUALLY");
|
||||
sb.AppendLine($"// Progress: {Math.Min(totalReviewed, AllAlarmKeys.Length)} / {AllAlarmKeys.Length} reviewed | Last updated: {lastUpdated}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("namespace InnovEnergy.App.Backend.Services;");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("public static class AlarmKnowledgeBaseChecked");
|
||||
sb.AppendLine("{");
|
||||
|
||||
sb.AppendLine(" public static readonly IReadOnlyDictionary<string, DiagnosticResponse> SinexcelAlarms =");
|
||||
sb.AppendLine(" new Dictionary<string, DiagnosticResponse>");
|
||||
sb.AppendLine(" {");
|
||||
foreach (var key in SinexcelKeys)
|
||||
AppendEntry(sb, key, improved);
|
||||
sb.AppendLine(" };");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine(" public static readonly IReadOnlyDictionary<string, DiagnosticResponse> GrowattAlarms =");
|
||||
sb.AppendLine(" new Dictionary<string, DiagnosticResponse>");
|
||||
sb.AppendLine(" {");
|
||||
foreach (var key in GrowattKeys)
|
||||
AppendEntry(sb, key, improved);
|
||||
sb.AppendLine(" };");
|
||||
sb.AppendLine();
|
||||
|
||||
// Emit the lookup method so the generated file is self-contained and can fully replace AlarmKnowledgeBase.cs
|
||||
sb.AppendLine(" public static DiagnosticResponse? TryGetDiagnosis(string alarmDescription)");
|
||||
sb.AppendLine(" {");
|
||||
sb.AppendLine(" if (string.IsNullOrWhiteSpace(alarmDescription)) return null;");
|
||||
sb.AppendLine(" var normalized = alarmDescription.Trim();");
|
||||
sb.AppendLine(" if (SinexcelAlarms.TryGetValue(normalized, out var s)) return s;");
|
||||
sb.AppendLine(" if (GrowattAlarms.TryGetValue(normalized, out var g)) return g;");
|
||||
sb.AppendLine(" var lower = normalized.ToLowerInvariant();");
|
||||
sb.AppendLine(" foreach (var kvp in SinexcelAlarms)");
|
||||
sb.AppendLine(" if (kvp.Key.ToLowerInvariant() == lower) return kvp.Value;");
|
||||
sb.AppendLine(" foreach (var kvp in GrowattAlarms)");
|
||||
sb.AppendLine(" if (kvp.Key.ToLowerInvariant() == lower) return kvp.Value;");
|
||||
sb.AppendLine(" return null;");
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine("}");
|
||||
|
||||
File.WriteAllText(CheckedFilePath, sb.ToString());
|
||||
Console.WriteLine($"[AlarmReviewService] AlarmKnowledgeBaseChecked.cs written ({Math.Min(totalReviewed, AllAlarmKeys.Length)}/{AllAlarmKeys.Length} reviewed).");
|
||||
var json = JsonConvert.SerializeObject(output, Formatting.Indented);
|
||||
var totalReviewed = Math.Min(progress.Batches.Count(b => b.Synthesized) * BatchSize, AllAlarmKeys.Length);
|
||||
Directory.CreateDirectory(ResourcesDir);
|
||||
File.WriteAllText(CheckedFilePath, json, System.Text.Encoding.UTF8);
|
||||
Console.WriteLine($"[AlarmReviewService] AlarmTranslationsChecked.de.json written ({totalReviewed}/{AllAlarmKeys.Length} reviewed).");
|
||||
}
|
||||
|
||||
private static void AppendEntry(StringBuilder sb, string key, Dictionary<string, DiagnosticResponse> improved)
|
||||
|
|
@ -1196,7 +1389,7 @@ render();
|
|||
"Irgendwo da draußen liest ein Kunde Ihre Worte um 2 Uhr nachts. Machen Sie sie beruhigend. 🌟",
|
||||
"Klarheit ist eine Superkraft. Heute setzen Sie sie ein. 💪",
|
||||
"Die beste Alarmmeldung ist eine, bei der der Kunde sagt: 'Ach so, das macht Sinn'. 💡",
|
||||
"Ihre 15 Minuten heute könnten hunderten von Kunden stundenlange Verwirrung ersparen. ⏱️",
|
||||
"Ihre 10 Minuten heute könnten hunderten von Kunden stundenlange Verwirrung ersparen. ⏱️",
|
||||
"Einfache Sprache ist schwer. Danke, dass Sie sich die Zeit nehmen, es richtig zu machen. ✍️",
|
||||
"Hinter jedem Alarmcode steckt ein echter Mensch, der einfach wissen möchte, ob sein Zuhause sicher ist. 🏠",
|
||||
"Sie übersetzen nicht nur Text — Sie übersetzen Ingenieursprache in Menschensprache. 🌍",
|
||||
|
|
@ -1217,7 +1410,7 @@ render();
|
|||
private static string BuildReviewerEmailHtml(string name, string reviewUrl, int batchNum, int alarmCount, string quote, bool isResend)
|
||||
{
|
||||
var urgentBanner = isResend
|
||||
? """<div style="background:#fdf3e7;border-left:4px solid #e67e22;padding:12px 16px;border-radius:0 4px 4px 0;margin-bottom:16px;font-size:13px">⚠️ <strong>Noch keine Rückmeldungen eingegangen.</strong> Dieselben Alarme werden erneut gesendet. Bitte bis 8 Uhr morgen früh einreichen.</div>"""
|
||||
? """<div style="background:#fdf3e7;border-left:4px solid #e67e22;padding:12px 16px;border-radius:0 4px 4px 0;margin-bottom:16px;font-size:13px">⚠️ <strong>Noch keine Rückmeldungen eingegangen.</strong> Dieselben Alarme werden erneut gesendet. Bitte bis 8:00 Uhr morgen früh einreichen.</div>"""
|
||||
: "";
|
||||
|
||||
return $"""
|
||||
|
|
@ -1233,7 +1426,7 @@ render();
|
|||
<tr><td style="background:#fce8ed;padding:22px 28px">
|
||||
{urgentBanner}
|
||||
<p style="margin-bottom:13px">Hallo <strong>{name}</strong>,</p>
|
||||
<p style="margin-bottom:13px">Bitte überprüfen Sie heute die <strong>{alarmCount} Alarmbeschreibungen</strong> und markieren Sie jede als <em>'Passt so'</em> oder bearbeiten Sie sie, um sie zu verbessern. Dies dauert etwa <strong>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>
|
||||
<div style="text-align:center;margin-bottom:22px">
|
||||
<a href="{reviewUrl}" style="background:#27ae60;color:#fff;text-decoration:none;padding:11px 30px;border-radius:6px;font-size:14px;font-weight:bold;display:inline-block">Überprüfung starten →</a>
|
||||
|
|
@ -1269,12 +1462,12 @@ render();
|
|||
private static async Task SendReminderEmailAsync(BatchRecord batch, string name, string email)
|
||||
{
|
||||
var reviewUrl = $"{BaseUrl}/ReviewAlarms?batch={batch.BatchNumber}&reviewer={Uri.EscapeDataString(name)}";
|
||||
var subject = $"Erinnerung: Alarmprüfung Stapel {batch.BatchNumber} bis 8 Uhr morgen abschließen";
|
||||
var subject = $"Erinnerung: Alarmprüfung Stapel {batch.BatchNumber} bis 8:00 Uhr morgen früh abschließen";
|
||||
var html = $"""
|
||||
<!DOCTYPE html><html><head><meta charset="utf-8"></head>
|
||||
<body style="font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#333;padding:24px;max-width:500px">
|
||||
<p>Hallo <strong>{name}</strong>,</p>
|
||||
<p style="margin-top:12px">Kurze Erinnerung — die heutige Alarmprüfung <strong>(Stapel {batch.BatchNumber})</strong> schließt um <strong>8 Uhr morgen früh</strong>. Es dauert nur 15 Minuten!</p>
|
||||
<p style="margin-top:12px">Kurze Erinnerung — die heutige Alarmprüfung <strong>(Stapel {batch.BatchNumber})</strong> schließt um <strong>8:00 Uhr morgen früh</strong>. Es dauert nur 10 Minuten!</p>
|
||||
<p style="margin:20px 0"><a href="{reviewUrl}" style="background:#3498db;color:#fff;text-decoration:none;padding:10px 24px;border-radius:6px;font-size:13px;display:inline-block">Überprüfung abschließen →</a></p>
|
||||
<p style="font-size:11px;color:#bbb">inesco Energy Monitor</p>
|
||||
</body></html>
|
||||
|
|
@ -1293,7 +1486,7 @@ render();
|
|||
var beforeAfterRows = new StringBuilder();
|
||||
foreach (var key in batch.AlarmKeys)
|
||||
{
|
||||
var original = AlarmKnowledgeBase.TryGetDiagnosis(key);
|
||||
var original = DiagnosticService.TryGetTranslation(key, "de") ?? AlarmKnowledgeBase.TryGetDiagnosis(key);
|
||||
var improved = batch.ImprovedEntries.TryGetValue(key, out var imp) ? imp : null;
|
||||
var label = GermanName(key);
|
||||
var changed = improved != null &&
|
||||
|
|
@ -1302,26 +1495,28 @@ render();
|
|||
!improved.NextSteps.SequenceEqual(original?.NextSteps ?? Array.Empty<string>()));
|
||||
|
||||
var statusColor = changed ? "#e67e22" : "#27ae60";
|
||||
var statusText = changed ? "Aktualisiert" : "Unverändert";
|
||||
var statusText = changed ? "KI aktualisiert" : "Unverändert";
|
||||
var correctUrl = $"{BaseUrl}/CorrectAlarm?batch={batch.BatchNumber}&key={Uri.EscapeDataString(key)}";
|
||||
|
||||
beforeAfterRows.Append($"""
|
||||
<tr><td colspan="2" style="padding:14px 0 4px;font-weight:bold;font-size:14px;color:#2c3e50;border-top:2px solid #eee">
|
||||
{label} <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>
|
||||
""");
|
||||
|
||||
// Explanation
|
||||
var origExp = original?.Explanation ?? "(none)";
|
||||
var newExp = improved?.Explanation ?? origExp;
|
||||
var expStyle = newExp != origExp ? "color:#c0392b;text-decoration:line-through" : "color:#555";
|
||||
beforeAfterRows.Append($"""
|
||||
<tr>
|
||||
<td style="padding:4px 12px 4px 0;vertical-align:top;width:50%;font-size:12px;color:#888;font-style:italic">Vorher</td>
|
||||
<td style="padding:4px 0;vertical-align:top;width:50%;font-size:12px;color:#888;font-style:italic">Nachher</td>
|
||||
<td style="padding:4px 12px 4px 0;vertical-align:top;width:50%;font-size:12px;color:#888;font-style:italic">Vorher (Deutsch)</td>
|
||||
<td style="padding:4px 0;vertical-align:top;width:50%;font-size:12px;color:#888;font-style:italic">Nachher (KI)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:2px 12px 8px 0;vertical-align:top;font-size:13px;{expStyle}">{System.Web.HttpUtility.HtmlEncode(origExp)}</td>
|
||||
<td style="padding:2px 0 8px;vertical-align:top;font-size:13px;color:#27ae60">{System.Web.HttpUtility.HtmlEncode(newExp)}</td>
|
||||
<td style="padding:2px 12px 8px 0;vertical-align:top;font-size:13px;color:#555">{System.Web.HttpUtility.HtmlEncode(origExp)}</td>
|
||||
<td style="padding:2px 0 8px;vertical-align:top;font-size:13px;color:{(newExp != origExp ? "#e67e22" : "#27ae60")}">{System.Web.HttpUtility.HtmlEncode(newExp)}</td>
|
||||
</tr>
|
||||
""");
|
||||
|
||||
|
|
@ -1336,7 +1531,7 @@ render();
|
|||
</td>
|
||||
<td style="padding:2px 0 6px;vertical-align:top;font-size:12px">
|
||||
<div style="font-size:11px;color:#888;margin-bottom:3px">Ursachen</div>
|
||||
{string.Join("", newCauses.Select(c => $"<div style='font-size:12px;color:#27ae60;padding-left:8px'>• {System.Web.HttpUtility.HtmlEncode(c)}</div>"))}
|
||||
{string.Join("", newCauses.Select(c => $"<div style='font-size:12px;color:{(!newCauses.SequenceEqual(origCauses) ? "#e67e22" : "#27ae60")};padding-left:8px'>• {System.Web.HttpUtility.HtmlEncode(c)}</div>"))}
|
||||
</td>
|
||||
</tr>
|
||||
""");
|
||||
|
|
@ -1352,7 +1547,7 @@ render();
|
|||
</td>
|
||||
<td style="padding:2px 0 10px;vertical-align:top;font-size:12px">
|
||||
<div style="font-size:11px;color:#888;margin-bottom:3px">Was zu tun ist</div>
|
||||
{string.Join("", newSteps.Select(s => $"<div style='font-size:12px;color:#27ae60;padding-left:8px'>• {System.Web.HttpUtility.HtmlEncode(s)}</div>"))}
|
||||
{string.Join("", newSteps.Select(s => $"<div style='font-size:12px;color:{(!newSteps.SequenceEqual(origSteps) ? "#e67e22" : "#27ae60")};padding-left:8px'>• {System.Web.HttpUtility.HtmlEncode(s)}</div>"))}
|
||||
</td>
|
||||
</tr>
|
||||
""");
|
||||
|
|
@ -1403,14 +1598,13 @@ render();
|
|||
<body style="font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#333;padding:24px;max-width:560px">
|
||||
<h2 style="color:#27ae60">✅ Alarm Review Campaign Complete</h2>
|
||||
<p style="margin-top:12px">All <strong>229 alarms</strong> have been reviewed and synthesized.</p>
|
||||
<p style="margin-top:8px"><strong>AlarmKnowledgeBaseChecked.cs</strong> is ready for cutover on the server.</p>
|
||||
<h3 style="margin-top:22px;color:#2c3e50">Cutover Steps</h3>
|
||||
<p style="margin-top:8px"><strong>AlarmTranslationsChecked.de.json</strong> is ready on the server at <code>Resources/AlarmTranslationsChecked.de.json</code>.</p>
|
||||
<h3 style="margin-top:22px;color:#2c3e50">Next Steps</h3>
|
||||
<ol style="margin-top:10px;line-height:2.2;padding-left:20px">
|
||||
<li>Download the checked file:<br><code>curl "{BaseUrl}/DownloadCheckedKnowledgeBase?authToken=YOUR_TOKEN" -o AlarmKnowledgeBaseChecked.cs</code></li>
|
||||
<li>Move it to <code>csharp/App/Backend/Services/</code></li>
|
||||
<li>Delete <code>AlarmKnowledgeBase.cs</code></li>
|
||||
<li>Rename class <code>AlarmKnowledgeBaseChecked</code> → <code>AlarmKnowledgeBase</code></li>
|
||||
<li>Run: <code>dotnet build && ./deploy.sh</code></li>
|
||||
<li>On your local machine, pull the latest <code>Resources/</code> folder from the server</li>
|
||||
<li>Run <code>generate_alarm_translations.py</code> — reads <code>AlarmTranslationsChecked.de.json</code>, generates <code>en.json</code>, <code>fr.json</code>, <code>it.json</code></li>
|
||||
<li>Update <code>DiagnosticService</code> to load <code>AlarmTranslations.en.json</code> for English</li>
|
||||
<li>Deploy: <code>cd csharp/App/Backend && ./deploy.sh</code></li>
|
||||
</ol>
|
||||
<h3 style="margin-top:22px;color:#2c3e50">Campaign Summary</h3>
|
||||
<table style="border-collapse:collapse;margin-top:8px">
|
||||
|
|
@ -1465,4 +1659,55 @@ render();
|
|||
|
||||
private static string EscapeForCSharp(string s) =>
|
||||
s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\r", "").Replace("\n", "\\n");
|
||||
|
||||
// ── Workday check (no emails on weekends or Swiss public holidays) ──────────
|
||||
|
||||
private static bool IsWorkday(DateTime date)
|
||||
{
|
||||
if (date.DayOfWeek == DayOfWeek.Saturday || date.DayOfWeek == DayOfWeek.Sunday)
|
||||
return false;
|
||||
return !IsSwissPublicHoliday(date.Date);
|
||||
}
|
||||
|
||||
private static bool IsSwissPublicHoliday(DateTime date)
|
||||
{
|
||||
var year = date.Year;
|
||||
|
||||
// Fixed federal holidays
|
||||
if (date == new DateTime(year, 1, 1)) return true; // New Year's Day
|
||||
if (date == new DateTime(year, 8, 1)) return true; // Swiss National Day
|
||||
if (date == new DateTime(year, 12, 25)) return true; // Christmas Day
|
||||
if (date == new DateTime(year, 12, 26)) return true; // St. Stephen's Day
|
||||
|
||||
// Easter-based holidays
|
||||
var easter = GetEasterSunday(year);
|
||||
if (date == easter.AddDays(-2)) return true; // Good Friday
|
||||
if (date == easter) return true; // Easter Sunday
|
||||
if (date == easter.AddDays(1)) return true; // Easter Monday
|
||||
if (date == easter.AddDays(39)) return true; // Ascension Day
|
||||
if (date == easter.AddDays(49)) return true; // Whit Sunday (Pfingstsonntag)
|
||||
if (date == easter.AddDays(50)) return true; // Whit Monday (Pfingstmontag)
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static DateTime GetEasterSunday(int year)
|
||||
{
|
||||
// Anonymous Gregorian algorithm
|
||||
int a = year % 19;
|
||||
int b = year / 100;
|
||||
int c = year % 100;
|
||||
int d = b / 4;
|
||||
int e = b % 4;
|
||||
int f = (b + 8) / 25;
|
||||
int g = (b - f + 1) / 3;
|
||||
int h = (19 * a + b - d - g + 15) % 30;
|
||||
int i = c / 4;
|
||||
int k = c % 4;
|
||||
int l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||
int m = (a + 11 * h + 22 * l) / 451;
|
||||
int month = (h + l - 7 * m + 114) / 31;
|
||||
int day = ((h + l - 7 * m + 114) % 31) + 1;
|
||||
return new DateTime(year, month, day);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,9 +33,10 @@ public static class DiagnosticService
|
|||
else
|
||||
_apiKey = apiKey;
|
||||
|
||||
// Load pre-generated translation files (de, fr, it) if available
|
||||
// Load pre-generated translation files (en, de, fr, it) if available
|
||||
// en.json is generated by generate_alarm_translations.py after the review campaign
|
||||
var resourcesDir = Path.Combine(AppContext.BaseDirectory, "Resources");
|
||||
foreach (var lang in new[] { "de", "fr", "it" })
|
||||
foreach (var lang in new[] { "en", "de", "fr", "it" })
|
||||
{
|
||||
var file = Path.Combine(resourcesDir, $"AlarmTranslations.{lang}.json");
|
||||
if (!File.Exists(file)) continue;
|
||||
|
|
@ -156,6 +157,12 @@ public static class DiagnosticService
|
|||
/// </summary>
|
||||
public static DiagnosticResponse? TryGetTranslation(string errorDescription, string language)
|
||||
{
|
||||
// Check JSON translations first (en.json exists after review campaign)
|
||||
if (Translations.TryGetValue(language, out var langDict) &&
|
||||
langDict.TryGetValue(errorDescription, out var translated))
|
||||
return translated;
|
||||
|
||||
// Fallback: English from compiled AlarmKnowledgeBase.cs (until en.json is deployed)
|
||||
if (language == "en")
|
||||
{
|
||||
var kb = AlarmKnowledgeBase.TryGetDiagnosis(errorDescription);
|
||||
|
|
@ -169,10 +176,6 @@ public static class DiagnosticService
|
|||
};
|
||||
}
|
||||
|
||||
if (Translations.TryGetValue(language, out var langDict) &&
|
||||
langDict.TryGetValue(errorDescription, out var translated))
|
||||
return translated;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -2,95 +2,49 @@
|
|||
"""
|
||||
generate_alarm_translations.py
|
||||
|
||||
One-time script: reads AlarmKnowledgeBase.cs, calls Mistral API to translate
|
||||
all alarm entries into German (de), French (fr), and Italian (it), and writes:
|
||||
Post-campaign script: reads AlarmTranslationsChecked.de.json (the reviewed and
|
||||
AI-synthesized German content), translates into English, French, and Italian,
|
||||
and writes:
|
||||
|
||||
Resources/AlarmTranslations.de.json ← backend uses these at startup
|
||||
Resources/AlarmTranslations.fr.json
|
||||
Resources/AlarmTranslations.it.json
|
||||
Resources/AlarmNames.de.json ← frontend lang file additions
|
||||
Resources/AlarmNames.fr.json
|
||||
Resources/AlarmNames.it.json
|
||||
Resources/AlarmTranslations.de.json ← replace with reviewed German
|
||||
Resources/AlarmTranslations.en.json ← back-translated from German
|
||||
Resources/AlarmTranslations.fr.json ← translated from German
|
||||
Resources/AlarmTranslations.it.json ← translated from German
|
||||
Services/AlarmKnowledgeBase.cs ← updated English source (keeps same structure)
|
||||
|
||||
Usage:
|
||||
Run this AFTER the review campaign is complete:
|
||||
export MISTRAL_API_KEY=your_key_here
|
||||
cd csharp/App/Backend
|
||||
python3 generate_alarm_translations.py
|
||||
|
||||
Output files can be reviewed/edited before committing.
|
||||
"""
|
||||
|
||||
import re
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import shutil
|
||||
from typing import Optional
|
||||
import requests
|
||||
|
||||
# ── Config ─────────────────────────────────────────────────────────────────
|
||||
|
||||
KNOWLEDGE_BASE_FILE = "Services/AlarmKnowledgeBase.cs"
|
||||
CHECKED_FILE = "Resources/AlarmTranslationsChecked.de.json"
|
||||
KNOWLEDGE_BASE = "Services/AlarmKnowledgeBase.cs"
|
||||
RESOURCES_DIR = "Resources"
|
||||
MISTRAL_URL = "https://api.mistral.ai/v1/chat/completions"
|
||||
MISTRAL_MODEL = "mistral-small-latest"
|
||||
BATCH_SIZE = 3 # alarms per API call — smaller = less chance of token truncation
|
||||
MISTRAL_MODEL = "mistral-large-latest"
|
||||
BATCH_SIZE = 5 # alarms per API call
|
||||
RETRY_DELAY = 5 # seconds between retries on rate-limit
|
||||
MAX_RETRIES = 3
|
||||
REQUEST_TIMEOUT = (10, 90) # (connect_timeout, read_timeout) in seconds
|
||||
REQUEST_TIMEOUT = (10, 90)
|
||||
|
||||
LANGUAGES = {
|
||||
"de": "German",
|
||||
TARGET_LANGUAGES = {
|
||||
"en": "English",
|
||||
"fr": "French",
|
||||
"it": "Italian",
|
||||
}
|
||||
|
||||
# ── Parsing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def split_camel_case(name: str) -> str:
|
||||
"""'AbnormalGridVoltage' → 'Abnormal Grid Voltage'"""
|
||||
return re.sub(r'(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])', ' ', name).strip()
|
||||
|
||||
|
||||
def parse_knowledge_base(filepath: str) -> dict:
|
||||
"""
|
||||
Parses AlarmKnowledgeBase.cs and returns a dict:
|
||||
{ "AlarmKey": { "Explanation": "...", "Causes": [...], "NextSteps": [...] } }
|
||||
"""
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
alarms = {}
|
||||
|
||||
# Find positions of all alarm key declarations: ["Key"] = new()
|
||||
key_matches = list(re.finditer(r'\["(\w+)"\]\s*=\s*new\(\)', content))
|
||||
|
||||
for i, key_match in enumerate(key_matches):
|
||||
key = key_match.group(1)
|
||||
start = key_match.start()
|
||||
end = key_matches[i + 1].start() if i + 1 < len(key_matches) else len(content)
|
||||
block = content[start:end]
|
||||
|
||||
# Explanation (single string)
|
||||
exp_match = re.search(r'Explanation\s*=\s*"((?:[^"\\]|\\.)*)"', block)
|
||||
explanation = exp_match.group(1) if exp_match else ""
|
||||
|
||||
# Causes (string array)
|
||||
causes_section = re.search(r'Causes\s*=\s*new\[\]\s*\{([^}]+)\}', block, re.DOTALL)
|
||||
causes = re.findall(r'"((?:[^"\\]|\\.)*)"', causes_section.group(1)) if causes_section else []
|
||||
|
||||
# NextSteps (string array)
|
||||
steps_section = re.search(r'NextSteps\s*=\s*new\[\]\s*\{([^}]+)\}', block, re.DOTALL)
|
||||
next_steps = re.findall(r'"((?:[^"\\]|\\.)*)"', steps_section.group(1)) if steps_section else []
|
||||
|
||||
if explanation or causes or next_steps:
|
||||
alarms[key] = {
|
||||
"Explanation": explanation,
|
||||
"Causes": causes,
|
||||
"NextSteps": next_steps,
|
||||
}
|
||||
|
||||
return alarms
|
||||
|
||||
|
||||
# ── Mistral API ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -102,8 +56,8 @@ def call_mistral(api_key: str, prompt: str) -> Optional[str]:
|
|||
body = {
|
||||
"model": MISTRAL_MODEL,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": 1400, # ~3 alarms × ~450 tokens each (German is verbose)
|
||||
"temperature": 0.1, # low for consistent translations
|
||||
"max_tokens": 1800,
|
||||
"temperature": 0.1,
|
||||
}
|
||||
|
||||
for attempt in range(1, MAX_RETRIES + 1):
|
||||
|
|
@ -114,9 +68,7 @@ def call_mistral(api_key: str, prompt: str) -> Optional[str]:
|
|||
time.sleep(RETRY_DELAY * attempt)
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
content = data["choices"][0]["message"]["content"].strip()
|
||||
# Strip markdown code fences if present
|
||||
content = resp.json()["choices"][0]["message"]["content"].strip()
|
||||
if content.startswith("```"):
|
||||
first_newline = content.index("\n")
|
||||
content = content[first_newline + 1:]
|
||||
|
|
@ -130,35 +82,24 @@ def call_mistral(api_key: str, prompt: str) -> Optional[str]:
|
|||
return None
|
||||
|
||||
|
||||
def translate_batch(api_key: str, batch: dict, language_name: str) -> Optional[dict]:
|
||||
def translate_batch(api_key: str, batch: dict, target_language: str) -> Optional[dict]:
|
||||
"""
|
||||
Translates a batch of alarms into the target language.
|
||||
Returns dict with same keys + translated content including a localized Name.
|
||||
Translates a batch of German alarm entries into the target language.
|
||||
Input: { "AlarmKey": { "Explanation": "...", "Causes": [...], "NextSteps": [...] } }
|
||||
Output: same structure in target language.
|
||||
"""
|
||||
# Build input JSON (only English content, no need to send back keys)
|
||||
input_data = {}
|
||||
for key, entry in batch.items():
|
||||
english_name = split_camel_case(key)
|
||||
input_data[key] = {
|
||||
"EnglishName": english_name,
|
||||
"Explanation": entry["Explanation"],
|
||||
"Causes": entry["Causes"],
|
||||
"NextSteps": entry["NextSteps"],
|
||||
}
|
||||
prompt = f"""You are translating battery energy storage system alarm descriptions from German into {target_language}.
|
||||
The source content has been reviewed by field engineers and is accurate.
|
||||
Translate faithfully — keep the same number of bullet points, same meaning, plain language for homeowners.
|
||||
|
||||
prompt = f"""You are translating battery energy storage system alarm descriptions into {language_name}.
|
||||
Translate each alarm entry. The "Name" should be a short (2-5 word) localized display title for the alarm.
|
||||
Keep technical terms accurate but use plain language a homeowner would understand.
|
||||
Input JSON (German):
|
||||
{json.dumps(batch, ensure_ascii=False, indent=2)}
|
||||
|
||||
Input JSON:
|
||||
{json.dumps(input_data, ensure_ascii=False, indent=2)}
|
||||
|
||||
Return ONLY a valid JSON object with the same alarm keys. Each value must have exactly these fields:
|
||||
Return ONLY a valid JSON object with the same alarm keys. Each value must have exactly:
|
||||
{{
|
||||
"Name": "short {language_name} title",
|
||||
"Explanation": "translated explanation sentence",
|
||||
"Causes": ["translated cause 1", "translated cause 2"],
|
||||
"NextSteps": ["translated step 1", "translated step 2"]
|
||||
"Explanation": "translated explanation (1 sentence)",
|
||||
"Causes": ["translated cause 1", ...],
|
||||
"NextSteps": ["translated step 1", ...]
|
||||
}}
|
||||
|
||||
Reply with ONLY the JSON object, no markdown, no extra text."""
|
||||
|
|
@ -168,18 +109,112 @@ Reply with ONLY the JSON object, no markdown, no extra text."""
|
|||
return None
|
||||
|
||||
try:
|
||||
result = json.loads(raw)
|
||||
return result
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f" JSON parse error: {e}")
|
||||
print(f" Raw response (first 300 chars): {raw[:300]}")
|
||||
print(f" Raw (first 300 chars): {raw[:300]}")
|
||||
return None
|
||||
|
||||
|
||||
# ── AlarmKnowledgeBase.cs generation ────────────────────────────────────────
|
||||
|
||||
def parse_kb_key_sections(filepath: str) -> dict:
|
||||
"""
|
||||
Reads AlarmKnowledgeBase.cs and returns {key: "Sinexcel"|"Growatt"}
|
||||
preserving the original section order.
|
||||
"""
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
sinexcel_match = re.search(r'SinexcelAlarms\s*=\s*new Dictionary.*?\{(.*?)^\s*\};', content, re.DOTALL | re.MULTILINE)
|
||||
growatt_match = re.search(r'GrowattAlarms\s*=\s*new Dictionary.*?\{(.*?)^\s*\};', content, re.DOTALL | re.MULTILINE)
|
||||
|
||||
result = {}
|
||||
if sinexcel_match:
|
||||
for key in re.findall(r'\["(\w+)"\]\s*=\s*new\(\)', sinexcel_match.group(1)):
|
||||
result[key] = "Sinexcel"
|
||||
if growatt_match:
|
||||
for key in re.findall(r'\["(\w+)"\]\s*=\s*new\(\)', growatt_match.group(1)):
|
||||
result[key] = "Growatt"
|
||||
return result
|
||||
|
||||
|
||||
def cs_escape(s: str) -> str:
|
||||
"""Escapes a string for use inside a C# double-quoted string literal."""
|
||||
return s.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
def write_knowledge_base_cs(filepath: str, en_translations: dict, key_sections: dict):
|
||||
"""
|
||||
Writes an updated AlarmKnowledgeBase.cs using the new English translations,
|
||||
preserving the original Sinexcel/Growatt section structure.
|
||||
"""
|
||||
sinexcel_keys = [k for k, s in key_sections.items() if s == "Sinexcel"]
|
||||
growatt_keys = [k for k, s in key_sections.items() if s == "Growatt"]
|
||||
|
||||
def entry_block(key: str) -> str:
|
||||
entry = en_translations.get(key)
|
||||
if not entry:
|
||||
return f' // [{key}] — no translation available\n'
|
||||
exp = cs_escape(entry.get("Explanation", ""))
|
||||
causes = ",\n ".join(f'"{cs_escape(c)}"' for c in entry.get("Causes", []))
|
||||
steps = ",\n ".join(f'"{cs_escape(s)}"' for s in entry.get("NextSteps", []))
|
||||
return (
|
||||
f' ["{key}"] = new()\n'
|
||||
f' {{\n'
|
||||
f' Explanation = "{exp}",\n'
|
||||
f' Causes = new[] {{ {causes} }},\n'
|
||||
f' NextSteps = new[] {{ {steps} }}\n'
|
||||
f' }},\n'
|
||||
)
|
||||
|
||||
lines = []
|
||||
lines.append("namespace InnovEnergy.App.Backend.Services;\n")
|
||||
lines.append("\n")
|
||||
lines.append("/// <summary>\n")
|
||||
lines.append("/// Static knowledge base for Sinexcel and Growatt alarms.\n")
|
||||
lines.append("/// Provides pre-defined diagnostics without requiring Mistral API calls.\n")
|
||||
lines.append("/// Updated by generate_alarm_translations.py after the review campaign.\n")
|
||||
lines.append("/// </summary>\n")
|
||||
lines.append("public static class AlarmKnowledgeBase\n")
|
||||
lines.append("{\n")
|
||||
lines.append(" public static DiagnosticResponse? TryGetDiagnosis(string alarmDescription)\n")
|
||||
lines.append(" {\n")
|
||||
lines.append(" if (string.IsNullOrWhiteSpace(alarmDescription)) return null;\n")
|
||||
lines.append(" var normalized = alarmDescription.Trim();\n")
|
||||
lines.append(" if (SinexcelAlarms.TryGetValue(normalized, out var s)) return s;\n")
|
||||
lines.append(" if (GrowattAlarms.TryGetValue(normalized, out var g)) return g;\n")
|
||||
lines.append(" var lower = normalized.ToLowerInvariant();\n")
|
||||
lines.append(" foreach (var kvp in SinexcelAlarms) if (kvp.Key.ToLowerInvariant() == lower) return kvp.Value;\n")
|
||||
lines.append(" foreach (var kvp in GrowattAlarms) if (kvp.Key.ToLowerInvariant() == lower) return kvp.Value;\n")
|
||||
lines.append(" return null;\n")
|
||||
lines.append(" }\n")
|
||||
lines.append("\n")
|
||||
lines.append(" // ── Sinexcel Alarms ──────────────────────────────────────────────────────\n")
|
||||
lines.append("\n")
|
||||
lines.append(" private static readonly IReadOnlyDictionary<string, DiagnosticResponse> SinexcelAlarms = new Dictionary<string, DiagnosticResponse>\n")
|
||||
lines.append(" {\n")
|
||||
for key in sinexcel_keys:
|
||||
lines.append(entry_block(key))
|
||||
lines.append(" };\n")
|
||||
lines.append("\n")
|
||||
lines.append(" // ── Growatt Alarms ───────────────────────────────────────────────────────\n")
|
||||
lines.append("\n")
|
||||
lines.append(" private static readonly IReadOnlyDictionary<string, DiagnosticResponse> GrowattAlarms = new Dictionary<string, DiagnosticResponse>\n")
|
||||
lines.append(" {\n")
|
||||
for key in growatt_keys:
|
||||
lines.append(entry_block(key))
|
||||
lines.append(" };\n")
|
||||
lines.append("}\n")
|
||||
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.writelines(lines)
|
||||
print(f" ✓ Wrote updated AlarmKnowledgeBase.cs ({len(sinexcel_keys)} Sinexcel + {len(growatt_keys)} Growatt keys)")
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def load_env_file(env_path: str) -> dict:
|
||||
"""Parse a simple KEY=VALUE .env file."""
|
||||
env = {}
|
||||
try:
|
||||
with open(env_path) as f:
|
||||
|
|
@ -194,12 +229,10 @@ def load_env_file(env_path: str) -> dict:
|
|||
|
||||
|
||||
def main():
|
||||
# Try environment variable first, then .env file in the same directory
|
||||
api_key = os.environ.get("MISTRAL_API_KEY", "").strip()
|
||||
if not api_key:
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
env_vars = load_env_file(os.path.join(script_dir, ".env"))
|
||||
api_key = env_vars.get("MISTRAL_API_KEY", "").strip()
|
||||
api_key = load_env_file(os.path.join(script_dir, ".env")).get("MISTRAL_API_KEY", "").strip()
|
||||
|
||||
if not api_key:
|
||||
print("ERROR: MISTRAL_API_KEY not found in environment or .env file.")
|
||||
|
|
@ -207,29 +240,32 @@ def main():
|
|||
|
||||
print("MISTRAL_API_KEY loaded.")
|
||||
|
||||
# Parse knowledge base
|
||||
print(f"Parsing {KNOWLEDGE_BASE_FILE}...")
|
||||
alarms = parse_knowledge_base(KNOWLEDGE_BASE_FILE)
|
||||
print(f" Found {len(alarms)} alarm entries.")
|
||||
|
||||
if not alarms:
|
||||
print("ERROR: No alarms parsed. Check the file path and format.")
|
||||
# Load reviewed German source
|
||||
if not os.path.exists(CHECKED_FILE):
|
||||
print(f"ERROR: {CHECKED_FILE} not found. Run the review campaign first.")
|
||||
sys.exit(1)
|
||||
|
||||
alarm_keys = list(alarms.keys())
|
||||
os.makedirs(RESOURCES_DIR, exist_ok=True)
|
||||
with open(CHECKED_FILE, "r", encoding="utf-8") as f:
|
||||
german_source = json.load(f)
|
||||
|
||||
# Process each language
|
||||
for lang_code, lang_name in LANGUAGES.items():
|
||||
alarm_keys = list(german_source.keys())
|
||||
print(f"Loaded {len(alarm_keys)} alarms from {CHECKED_FILE}.")
|
||||
|
||||
# Step 1: copy reviewed German as the new de.json
|
||||
de_out = os.path.join(RESOURCES_DIR, "AlarmTranslations.de.json")
|
||||
shutil.copy(CHECKED_FILE, de_out)
|
||||
print(f"\n✓ Copied reviewed German → {de_out}")
|
||||
|
||||
# Step 2: translate to en, fr, it
|
||||
all_translations = {} # lang_code → {key → entry}
|
||||
for lang_code, lang_name in TARGET_LANGUAGES.items():
|
||||
print(f"\n── Translating to {lang_name} ({lang_code}) ──")
|
||||
|
||||
translations = {} # key → {Name, Explanation, Causes, NextSteps}
|
||||
alarm_name_keys = {} # "alarm_Key" → translated name (for lang JSON files)
|
||||
translations = {}
|
||||
failed_keys = []
|
||||
|
||||
# Split into batches
|
||||
batches = [
|
||||
{k: alarms[k] for k in alarm_keys[i:i + BATCH_SIZE]}
|
||||
{k: german_source[k] for k in alarm_keys[i:i + BATCH_SIZE]}
|
||||
for i in range(0, len(alarm_keys), BATCH_SIZE)
|
||||
]
|
||||
|
||||
|
|
@ -240,7 +276,7 @@ def main():
|
|||
result = translate_batch(api_key, batch, lang_name)
|
||||
|
||||
if result is None:
|
||||
print(f" FAILED batch {batch_num} — will mark keys as failed")
|
||||
print(f" FAILED batch {batch_num} — marking keys as failed")
|
||||
failed_keys.extend(keys_in_batch)
|
||||
continue
|
||||
|
||||
|
|
@ -252,32 +288,32 @@ def main():
|
|||
"Causes": entry.get("Causes", []),
|
||||
"NextSteps": entry.get("NextSteps", []),
|
||||
}
|
||||
alarm_name_keys[f"alarm_{key}"] = entry.get("Name", split_camel_case(key))
|
||||
else:
|
||||
print(f" WARNING: key '{key}' missing from batch result")
|
||||
failed_keys.append(key)
|
||||
|
||||
# Small pause between batches to avoid rate limits
|
||||
if batch_num < len(batches):
|
||||
time.sleep(1)
|
||||
|
||||
# Write backend translation file
|
||||
backend_file = os.path.join(RESOURCES_DIR, f"AlarmTranslations.{lang_code}.json")
|
||||
with open(backend_file, "w", encoding="utf-8") as f:
|
||||
all_translations[lang_code] = translations
|
||||
out_file = os.path.join(RESOURCES_DIR, f"AlarmTranslations.{lang_code}.json")
|
||||
with open(out_file, "w", encoding="utf-8") as f:
|
||||
json.dump(translations, f, ensure_ascii=False, indent=2)
|
||||
print(f" Wrote {len(translations)} entries → {backend_file}")
|
||||
|
||||
# Write frontend alarm name file (to be merged into lang JSON)
|
||||
names_file = os.path.join(RESOURCES_DIR, f"AlarmNames.{lang_code}.json")
|
||||
with open(names_file, "w", encoding="utf-8") as f:
|
||||
json.dump(alarm_name_keys, f, ensure_ascii=False, indent=2)
|
||||
print(f" Wrote {len(alarm_name_keys)} name keys → {names_file}")
|
||||
print(f" ✓ Wrote {len(translations)} entries → {out_file}")
|
||||
|
||||
if failed_keys:
|
||||
print(f" FAILED keys ({len(failed_keys)}): {failed_keys}")
|
||||
print(f" ⚠ Failed keys ({len(failed_keys)}): {failed_keys}")
|
||||
|
||||
print("\n✓ Done. Review the output files in Resources/ before committing.")
|
||||
print(" Next: merge AlarmNames.*.json entries into src/lang/de.json, fr.json, it.json")
|
||||
# Step 3: update AlarmKnowledgeBase.cs with the new English back-translation
|
||||
print("\n── Updating AlarmKnowledgeBase.cs ──")
|
||||
if "en" in all_translations and os.path.exists(KNOWLEDGE_BASE):
|
||||
key_sections = parse_kb_key_sections(KNOWLEDGE_BASE)
|
||||
write_knowledge_base_cs(KNOWLEDGE_BASE, all_translations["en"], key_sections)
|
||||
else:
|
||||
print(" Skipped — en.json not generated or AlarmKnowledgeBase.cs not found.")
|
||||
|
||||
print("\n✓ Done. Review the output files before deploying.")
|
||||
print(" Next: cd csharp/App/Backend && dotnet build && ./deploy.sh")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
Loading…
Reference in New Issue