using System.Text; using System.Text.RegularExpressions; using Flurl.Http; using InnovEnergy.Lib.Mailer; using MailKit.Net.Smtp; using MailKit.Security; using MimeKit; using Newtonsoft.Json; namespace InnovEnergy.App.Backend.Services; // ── Models ──────────────────────────────────────────────────────────────────── public class AlarmReviewProgress { [JsonProperty("startedAt")] public string StartedAt { get; set; } = ""; [JsonProperty("batches")] public List Batches { get; set; } = new(); } public class BatchRecord { [JsonProperty("batchNumber")] public int BatchNumber { get; set; } [JsonProperty("sentDate")] public string SentDate { get; set; } = ""; [JsonProperty("alarmKeys")] public List AlarmKeys { get; set; } = new(); [JsonProperty("resendCount")] public int ResendCount { get; set; } [JsonProperty("synthesized")] public bool Synthesized { get; set; } [JsonProperty("synthesizedAt")] public string? SynthesizedAt { get; set; } [JsonProperty("submissions")] public Dictionary Submissions { get; set; } = new(); [JsonProperty("improvedEntries")] public Dictionary ImprovedEntries{ get; set; } = new(); [JsonProperty("note")] public string? Note { get; set; } } public class ReviewerSubmission { [JsonProperty("submittedAt")] public string SubmittedAt { get; set; } = ""; [JsonProperty("feedbacks")] public List Feedbacks { get; set; } = new(); } public class ReviewFeedback { [JsonProperty("explanationOk")] public bool ExplanationOk { get; set; } [JsonProperty("explanation")] public string Explanation { get; set; } = ""; [JsonProperty("causesOk")] public bool CausesOk { get; set; } [JsonProperty("causes")] public List Causes { get; set; } = new(); [JsonProperty("stepsOk")] public bool StepsOk { get; set; } [JsonProperty("nextSteps")] public List NextSteps { get; set; } = new(); [JsonProperty("comment")] public string Comment { get; set; } = ""; } public class AlarmCorrectionRequest { [JsonProperty("batchNumber")] public int BatchNumber { get; set; } [JsonProperty("alarmKey")] public string? AlarmKey { get; set; } [JsonProperty("explanation")] public string? Explanation { get; set; } [JsonProperty("causes")] public List? Causes { get; set; } [JsonProperty("nextSteps")] public List? NextSteps { get; set; } } // ── Service ─────────────────────────────────────────────────────────────────── public static class AlarmReviewService { // ── Configuration ───────────────────────────────────────────────────────── private static readonly (string Name, string Email)[] Reviewers = { ("Rüdiger", "junghans@inesco.energy"), ("Nico", "lapp@inesco.energy"), ("Fabio", "niederberger@inesco.energy"), ("Jan", "dustmann@inesco.energy"), }; private const string AdminEmail = "liu@inesco.energy"; private const string BaseUrl = "https://monitor.inesco.energy/api"; private const int BatchSize = 10; // ── File paths ───────────────────────────────────────────────────────────── 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(ResourcesDir, "AlarmTranslationsChecked.de.json"); // ── German alarm display names (loaded from AlarmNames.de.json) ──────────── private static IReadOnlyDictionary _germanNames = new Dictionary(); public static void LoadGermanNames() { var file = Path.Combine(AppContext.BaseDirectory, "Resources", "AlarmNames.de.json"); if (!File.Exists(file)) return; try { var raw = JsonConvert.DeserializeObject>(File.ReadAllText(file)); if (raw is not null) _germanNames = raw; Console.WriteLine($"[AlarmReviewService] Loaded {raw?.Count ?? 0} German alarm names."); } catch (Exception ex) { Console.Error.WriteLine($"[AlarmReviewService] Failed to load AlarmNames.de.json: {ex.Message}"); } } private static string GermanName(string key) => _germanNames.TryGetValue(key, out var name) ? name : SplitCamelCase(key); // ── Alarm key ordering: 135 Sinexcel + 94 Growatt = 229 total ──────────── private static readonly string[] SinexcelKeys = { "AbnormalGridVoltage", "AbnormalGridFrequency", "InvertedSequenceOfGridVoltage", "GridVoltagePhaseLoss", "AbnormalGridCurrent", "AbnormalOutputVoltage", "AbnormalOutputFrequency", "AbnormalNullLine", "AbnormalOffGridOutputVoltage", "ExcessivelyHighAmbientTemperature", "ExcessiveRadiatorTemperature", "PcbOvertemperature", "DcConverterOvertemperature", "InverterOvertemperatureAlarm", "InverterOvertemperature", "DcConverterOvertemperatureAlarm", "InsulationFault", "LeakageProtectionFault", "AbnormalLeakageSelfCheck", "PoorGrounding", "FanFault", "AuxiliaryPowerFault", "ModelCapacityFault", "AbnormalLightningArrester", "IslandProtection", "Battery1NotConnected", "Battery1Overvoltage", "Battery1Undervoltage", "Battery1DischargeEnd", "Battery1Inverted", "Battery1OverloadTimeout", "Battery1SoftStartFailure", "Battery1PowerTubeFault", "Battery1InsufficientPower", "Battery1BackupProhibited", "Battery2NotConnected", "Battery2Overvoltage", "Battery2Undervoltage", "Battery2DischargeEnd", "Battery2Inverted", "Battery2OverloadTimeout", "Battery2SoftStartFailure", "Battery2PowerTubeFault", "Battery2InsufficientPower", "Battery2BackupProhibited", "LithiumBattery1ChargeForbidden", "LithiumBattery1DischargeForbidden", "LithiumBattery2ChargeForbidden", "LithiumBattery2DischargeForbidden", "LithiumBattery1Full", "LithiumBattery1DischargeEnd", "LithiumBattery2Full", "LithiumBattery2DischargeEnd", "LeadBatteryTemperatureAbnormality", "BatteryAccessMethodError", "Pv1NotAccessed", "Pv1Overvoltage", "AbnormalPv1CurrentSharing", "Pv1PowerTubeFault", "Pv1SoftStartFailure", "Pv1OverloadTimeout", "Pv1InsufficientPower", "Photovoltaic1Overcurrent", "Pv2NotAccessed", "Pv2Overvoltage", "AbnormalPv2CurrentSharing", "Pv2PowerTubeFault", "Pv2SoftStartFailure", "Pv2OverloadTimeout", "Pv2InsufficientPower", "Pv3NotConnected", "Pv3Overvoltage", "Pv3AverageCurrentAnomaly", "Pv3PowerTubeFailure", "Pv3SoftStartFailure", "Pv3OverloadTimeout", "Pv3ReverseConnection", "Pv4NotConnected", "Pv4Overvoltage", "Pv4AverageCurrentAnomaly", "Pv4PowerTubeFailure", "Pv4SoftStartFailure", "Pv4OverloadTimeout", "Pv4ReverseConnection", "InsufficientPhotovoltaicPower", "DcBusOvervoltage", "DcBusUndervoltage", "DcBusVoltageUnbalance", "BusSlowOvervoltage", "HardwareBusOvervoltage", "BusSoftStartFailure", "InverterPowerTubeFault", "HardwareOvercurrent", "DcConverterOvervoltage", "DcConverterHardwareOvervoltage", "DcConverterOvercurrent", "DcConverterHardwareOvercurrent", "DcConverterResonatorOvercurrent", "SystemOutputOverload", "InverterOverload", "InverterOverloadTimeout", "LoadPowerOverload", "BalancedCircuitOverloadTimeout", "InverterSoftStartFailure", "Dsp1ParameterSettingFault", "Dsp2ParameterSettingFault", "DspVersionCompatibilityFault", "CpldVersionCompatibilityFault", "CpldCommunicationFault", "DspCommunicationFault", "OutputVoltageDcOverlimit", "OutputCurrentDcOverlimit", "RelaySelfCheckFails", "InverterRelayOpen", "InverterRelayShortCircuit", "OpenCircuitOfPowerGridRelay", "ShortCircuitOfPowerGridRelay", "GeneratorRelayOpenCircuit", "GeneratorRelayShortCircuit", "AbnormalInverter", "ParallelCommunicationAlarm", "ParallelModuleMissing", "DuplicateMachineNumbersForParallelModules", "ParameterConflictInParallelModule", "SystemDerating", "PvAccessMethodErrorAlarm", "ReservedAlarms4", "ReservedAlarms5", "ReverseMeterConnection", "InverterSealPulse", "AbnormalDieselGeneratorVoltage", "AbnormalDieselGeneratorFrequency", "DieselGeneratorVoltageReverseSequence", "DieselGeneratorVoltageOutOfPhase", "GeneratorOverload", }; private static readonly string[] GrowattKeys = { "StringFault", "PvStringPidQuickConnectAbnormal", "DcSpdFunctionAbnormal", "PvShortCircuited", "PvBoostDriverAbnormal", "AcSpdFunctionAbnormal", "DcFuseBlown", "DcInputVoltageTooHigh", "PvReversed", "PidFunctionAbnormal", "PvStringDisconnected", "PvStringCurrentUnbalanced", "NoUtilityGrid", "GridVoltageOutOfRange", "GridFrequencyOutOfRange", "Overload", "MeterDisconnected", "MeterReverselyConnected", "LinePeVoltageAbnormal", "PhaseSequenceError", "FanFailure", "MeterAbnormal", "OptimizerCommunicationAbnormal", "OverTemperature", "OverTemperatureAlarm", "NtcTemperatureSensorBroken", "SyncSignalAbnormal", "GridStartupConditionsNotMet", "BatteryCommunicationFailure", "BatteryDisconnected", "BatteryVoltageTooHigh", "BatteryVoltageTooLow", "BatteryReverseConnected", "LeadAcidTempSensorDisconnected", "BatteryTemperatureOutOfRange", "BmsFault", "LithiumBatteryOverload", "BmsCommunicationAbnormal", "BatterySpdAbnormal", "OutputDcComponentBiasAbnormal", "DcComponentOverHighOutputVoltage", "OffGridOutputVoltageTooLow", "OffGridOutputVoltageTooHigh", "OffGridOutputOverCurrent", "OffGridBusVoltageTooLow", "OffGridOutputOverload", "BalancedCircuitAbnormal", "ExportLimitationFailSafe", "DcBiasAbnormal", "HighDcComponentOutputCurrent", "BusVoltageSamplingAbnormal", "RelayFault", "BusVoltageAbnormal", "InternalCommunicationFailure", "TemperatureSensorDisconnected", "IgbtDriveFault", "EepromError", "AuxiliaryPowerAbnormal", "DcAcOvercurrentProtection", "CommunicationProtocolMismatch", "DspComFirmwareMismatch", "DspSoftwareHardwareMismatch", "CpldAbnormal", "RedundancySamplingInconsistent", "PwmPassThroughSignalFailure", "AfciSelfTestFailure", "PvCurrentSamplingAbnormal", "AcCurrentSamplingAbnormal", "BusSoftbootFailure", "EpoFault", "MonitoringChipBootVerificationFailed", "BmsCommunicationFailure", "BmsChargeDischargeFailure", "BatteryVoltageLow", "BatteryVoltageHigh", "BatteryTemperatureAbnormal", "BatteryReversed", "BatteryOpenCircuit", "BatteryOverloadProtection", "Bus2VoltageAbnormal", "BatteryChargeOcp", "BatteryDischargeOcp", "BatterySoftStartFailed", "EpsOutputShortCircuited", "OffGridBusVoltageLow", "OffGridTerminalVoltageAbnormal", "SoftStartFailed", "OffGridOutputVoltageAbnormal", "BalancedCircuitSelfTestFailed", "HighDcComponentOutputVoltage", "OffGridParallelSignalAbnormal", "AFCIFault", "GFCIHigh", "PVVoltageHigh", }; private static readonly string[] AllAlarmKeys = SinexcelKeys.Concat(GrowattKeys).ToArray(); private static readonly HashSet SinexcelKeySet = new(SinexcelKeys); // ── Scheduler ───────────────────────────────────────────────────────────── private static Timer? _morningTimer; private static Timer? _afternoonTimer; private static bool _synthesizing; public static void StartDailyScheduler() { LoadGermanNames(); ScheduleTimer(ref _morningTimer, 8, 0, () => RunMorningJobAsync() .GetAwaiter().GetResult()); ScheduleTimer(ref _afternoonTimer, 14, 0, () => RunAfternoonJobAsync().GetAwaiter().GetResult()); Console.WriteLine("[AlarmReviewService] Daily scheduler started (8AM + 2PM jobs)."); } /// Stops the scheduler and deletes the progress file. Safe to call at any time. public static void StopCampaign() { _morningTimer?.Dispose(); _afternoonTimer?.Dispose(); _morningTimer = null; _afternoonTimer = null; _testBatch = null; if (File.Exists(ProgressFile)) File.Delete(ProgressFile); Console.WriteLine("[AlarmReviewService] Campaign stopped and progress file deleted."); } private static void ScheduleTimer(ref Timer? timer, int hour, int minute, Action action) { var now = DateTime.Now; var next = now.Date.AddHours(hour).AddMinutes(minute); if (now >= next) next = next.AddDays(1); var delay = next - now; timer = new Timer(_ => { try { action(); } catch (Exception ex) { Console.Error.WriteLine($"[AlarmReviewService] Timer error: {ex.Message}"); } }, null, delay, TimeSpan.FromDays(1)); Console.WriteLine($"[AlarmReviewService] Next {hour:D2}:{minute:D2} job scheduled at {next:yyyy-MM-dd HH:mm}"); } // ── Morning job (8AM) ────────────────────────────────────────────────────── 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; var current = progress.Batches.LastOrDefault(); if (current == null) return; 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) { const int MaxResends = 3; if (current.ResendCount >= MaxResends) { // No responses after 3 resends — auto-advance using original content Console.WriteLine($"[AlarmReviewService] Batch {current.BatchNumber}: still 0 submissions after {MaxResends} resends — auto-advancing with original content."); foreach (var key in current.AlarmKeys) { var original = DiagnosticService.TryGetTranslation(key, "de") ?? AlarmKnowledgeBase.TryGetDiagnosis(key); if (original != null) current.ImprovedEntries[key] = original; } current.Synthesized = true; current.SynthesizedAt = DateTime.UtcNow.ToString("O"); current.Note = "Auto-advanced after 3 resends with no reviewer responses — original content preserved."; SaveProgress(progress); RegenerateCheckedFile(progress); await SendAdminStallAlertAsync(current); await SendNextBatchAsync(progress); } else { current.ResendCount++; SaveProgress(progress); Console.WriteLine($"[AlarmReviewService] Batch {current.BatchNumber}: 0 submissions — resending (attempt #{current.ResendCount}/{MaxResends})."); await SendBatchEmailsAsync(current, isResend: true); await SendAdminStallAlertAsync(current); } } else { // SynthesizeBatchAsync will call SendNextBatchAsync internally when done await SynthesizeBatchAsync(current, progress); } } } 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) { Console.WriteLine("[AlarmReviewService] All 229 alarms reviewed! Sending completion email."); await SendAdminCompletionEmailAsync(progress); return; } var batch = CreateNextBatch(progress); progress.Batches.Add(batch); SaveProgress(progress); await SendBatchEmailsAsync(batch, isResend: false); var totalReviewed = progress.Batches.Count(b => b.Synthesized) * BatchSize; Console.WriteLine($"[AlarmReviewService] Batch {batch.BatchNumber} sent. Progress: {totalReviewed}/{AllAlarmKeys.Length}."); } // ── Afternoon job (2PM) ──────────────────────────────────────────────────── 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; var current = progress.Batches.LastOrDefault(); if (current == null || current.Synthesized) return; foreach (var (name, email) in Reviewers) { var key = name.ToLowerInvariant(); if (!current.Submissions.TryGetValue(key, out var sub) || sub == null) await SendReminderEmailAsync(current, name, email); } } // ── Progress persistence ─────────────────────────────────────────────────── private static readonly object _fileLock = new(); private static readonly object _submitLock = new(); // serializes the read-modify-write in SubmitFeedback private static AlarmReviewProgress? LoadProgress() { if (!File.Exists(ProgressFile)) return null; try { lock (_fileLock) { var json = File.ReadAllText(ProgressFile); return JsonConvert.DeserializeObject(json); } } catch (Exception ex) { Console.Error.WriteLine($"[AlarmReviewService] Failed to load progress: {ex.Message}"); return null; } } private static void SaveProgress(AlarmReviewProgress p) { lock (_fileLock) { var json = JsonConvert.SerializeObject(p, Formatting.Indented); File.WriteAllText(ProgressFile, json); } } private static BatchRecord CreateNextBatch(AlarmReviewProgress progress) { var startIdx = progress.Batches.Count * BatchSize; var keys = AllAlarmKeys.Skip(startIdx).Take(BatchSize).ToList(); return new BatchRecord { BatchNumber = progress.Batches.Count + 1, SentDate = DateTime.Now.ToString("yyyy-MM-dd"), AlarmKeys = keys, Submissions = Reviewers.ToDictionary(r => r.Name.ToLowerInvariant(), _ => (ReviewerSubmission?)null), }; } // ── Public API (called by Controller) ───────────────────────────────────── /// Starts the campaign: creates progress file and sends Batch 1. public static void StartCampaign() { if (File.Exists(ProgressFile)) { Console.WriteLine("[AlarmReviewService] Campaign already started."); return; } var progress = new AlarmReviewProgress { StartedAt = DateTime.UtcNow.ToString("O") }; SaveProgress(progress); Task.Run(async () => { var batch = CreateNextBatch(progress); progress.Batches.Add(batch); SaveProgress(progress); await SendBatchEmailsAsync(batch, isResend: false); Console.WriteLine("[AlarmReviewService] Campaign started! Batch 1 sent."); }).GetAwaiter().GetResult(); } /// /// Sends a test batch of 10 alarms ONLY to the admin email so they can experience the form /// before the real campaign starts. Uses reviewer name "Admin" and batch number 0. /// Does NOT affect the real campaign progress file. /// public static async Task SendTestBatchAsync() { var testBatch = new BatchRecord { BatchNumber = 0, SentDate = DateTime.Now.ToString("yyyy-MM-dd"), AlarmKeys = AllAlarmKeys.Take(BatchSize).ToList(), Submissions = new Dictionary { ["Admin"] = null }, }; var testQuote = DailyQuotes[0]; var reviewUrl = $"{BaseUrl}/ReviewAlarms?batch=0&reviewer=Admin"; var html = BuildReviewerEmailHtml( name: "Rüdiger", reviewUrl: reviewUrl, batchNum: 1, alarmCount: BatchSize, quote: testQuote, isResend: false); await SendEmailAsync(AdminEmail, $"Alarmprüfung · Stapel 1 von {(int)Math.Ceiling((double)AllAlarmKeys.Length / BatchSize)} — Bitte heute prüfen", html); // Also store the test batch in memory so the review page can be served _testBatch = testBatch; Console.WriteLine("[AlarmReviewService] Test batch email sent to admin."); } private static BatchRecord? _testBatch; /// Returns the HTML review page for a given batch and reviewer. public static string? GetReviewPage(int batchNumber, string reviewerName) { // Batch 0 = admin test — no auth, no campaign needed if (batchNumber == 0) { var tb = _testBatch; if (tb == null) return null; return BuildReviewPage(tb, reviewerName); } var progress = LoadProgress(); if (progress == null) return null; var batch = progress.Batches.FirstOrDefault(b => b.BatchNumber == batchNumber); if (batch == null) return null; if (!Reviewers.Any(r => r.Name.Equals(reviewerName, StringComparison.OrdinalIgnoreCase))) return null; return BuildReviewPage(batch, reviewerName); } /// Saves reviewer feedback. Triggers synthesis if all 4 reviewers have submitted. public static bool SubmitFeedback(int batchNumber, string? reviewerName, List? feedbacks) { if (string.IsNullOrWhiteSpace(reviewerName)) return false; // Batch 0 = test mode — handled separately in Controller via PreviewSynthesisAsync if (batchNumber == 0) return true; BatchRecord? batchForSynthesis = null; AlarmReviewProgress? progressForSynthesis = null; lock (_submitLock) // atomic read-modify-write prevents two reviewers corrupting the file { var progress = LoadProgress(); if (progress == null) return false; var batch = progress.Batches.FirstOrDefault(b => b.BatchNumber == batchNumber); if (batch == null || batch.Synthesized) return false; var reviewerKey = reviewerName.ToLowerInvariant(); if (!Reviewers.Any(r => r.Name.ToLowerInvariant() == reviewerKey)) return false; if (feedbacks == null || feedbacks.Count != batch.AlarmKeys.Count) return false; if (feedbacks.Any(f => f == null)) return false; foreach (var f in feedbacks) { f.Explanation ??= ""; f.Comment ??= ""; f.Causes = f.Causes?.Select(c => c ?? "").ToList() ?? new List(); f.NextSteps = f.NextSteps?.Select(s => s ?? "").ToList() ?? new List(); } // Re-submission before synthesis is fine — reviewer can correct a mistake batch.Submissions[reviewerKey] = new ReviewerSubmission { SubmittedAt = DateTime.UtcNow.ToString("O"), Feedbacks = feedbacks, }; SaveProgress(progress); var submittedCount = batch.Submissions.Values.Count(s => s != null); Console.WriteLine($"[AlarmReviewService] Batch {batchNumber}: {reviewerName} submitted ({submittedCount}/{Reviewers.Length})."); if (submittedCount == Reviewers.Length) { Console.WriteLine($"[AlarmReviewService] Batch {batchNumber}: All {Reviewers.Length} reviewers done — synthesizing immediately."); batchForSynthesis = batch; progressForSynthesis = progress; } } if (batchForSynthesis != null) _ = Task.Run(async () => await SynthesizeBatchAsync(batchForSynthesis, progressForSynthesis!)); return true; } /// /// Dry-run synthesis for batch 0 (test). Runs AI against submitted feedback and returns /// HTML showing before/after for each alarm — nothing is saved to disk. /// public static async Task PreviewSynthesisAsync(List? feedbacks) { var testBatch = _testBatch; if (testBatch == null) return "
⚠️
Test-Batch abgelaufen
Bitte erneut einen Test-E-Mail senden und nochmal versuchen.
"; if (feedbacks == null || feedbacks.Count != testBatch.AlarmKeys.Count || feedbacks.Any(f => f == null)) return "
⚠️
Ungültige Eingabe
"; foreach (var f in feedbacks) { f.Explanation ??= ""; f.Comment ??= ""; f.Causes = f.Causes?.Select(c => c ?? "").ToList() ?? new List(); f.NextSteps = f.NextSteps?.Select(s => s ?? "").ToList() ?? new List(); } var mistralAvailable = !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("MISTRAL_API_KEY")); // Run all synthesis calls in parallel var synthTasks = testBatch.AlarmKeys.Select(async (key, i) => { var original = DiagnosticService.TryGetTranslation(key, "de") ?? AlarmKnowledgeBase.TryGetDiagnosis(key); var fb = feedbacks[i]; var anyChanges = !fb.ExplanationOk || !fb.CausesOk || !fb.StepsOk; DiagnosticResponse? synthesized = null; if (anyChanges && mistralAvailable && original != null) synthesized = await CallMistralForSynthesisAsync(key, original, new List { fb }); return (key, original, synthesized, fb, anyChanges); }); var results = await Task.WhenAll(synthTasks); var sb = new StringBuilder(); sb.Append("
"); sb.Append("
"); sb.Append("
Synthese-Vorschau (Testlauf)
"); sb.Append("
Nichts wurde gespeichert. Hier sehen Sie, was die KI mit Ihren Änderungen synthetisieren würde:
"); sb.Append("
"); if (!mistralAvailable) sb.Append("
⚠️ Mistral API nicht konfiguriert — Es werden Ihre Änderungen angezeigt, ohne KI-Synthese.
"); foreach (var (key, original, synthesized, fb, anyChanges) in results) { if (original == null) continue; var label = GermanName(key); var badgeClass = SinexcelKeySet.Contains(key) ? "sin" : "gro"; var badgeName = SinexcelKeySet.Contains(key) ? "Sinexcel" : "Growatt"; var statusText = !anyChanges ? "Keine Änderungen" : (synthesized != null ? "KI synthetisiert" : "Ihre Änderung (kein KI)"); var statusColor = !anyChanges ? "#27ae60" : (synthesized != null ? "#e67e22" : "#888"); // What will actually be used: AI result, or reviewer's direct edit if no AI var finalResult = synthesized ?? new DiagnosticResponse { Explanation = fb.ExplanationOk ? original.Explanation : fb.Explanation, Causes = fb.CausesOk ? original.Causes : fb.Causes, NextSteps = fb.StepsOk ? original.NextSteps : fb.NextSteps, }; sb.Append("
"); sb.Append($"
{badgeName}{System.Web.HttpUtility.HtmlEncode(label)}{statusText}
"); if (!anyChanges) { sb.Append("
✓ Alle Abschnitte als korrekt markiert — keine Änderungen.
"); } else { sb.Append(""); sb.Append(""); // Explanation sb.Append($""); sb.Append($""); // Causes sb.Append(""); // Next Steps sb.Append(""); sb.Append("
Vorher (Original)Nachher (Synthese)
{System.Web.HttpUtility.HtmlEncode(original.Explanation)}{System.Web.HttpUtility.HtmlEncode(finalResult.Explanation)}
"); sb.Append("
URSACHEN
"); foreach (var c in original.Causes) sb.Append($"
• {System.Web.HttpUtility.HtmlEncode(c)}
"); sb.Append("
"); sb.Append("
URSACHEN
"); foreach (var c in finalResult.Causes) sb.Append($"
• {System.Web.HttpUtility.HtmlEncode(c)}
"); sb.Append("
"); sb.Append("
WAS ZU TUN IST
"); foreach (var s in original.NextSteps) sb.Append($"
• {System.Web.HttpUtility.HtmlEncode(s)}
"); sb.Append("
"); sb.Append("
WAS ZU TUN IST
"); foreach (var s in finalResult.NextSteps) sb.Append($"
• {System.Web.HttpUtility.HtmlEncode(s)}
"); sb.Append("
"); } sb.Append("
"); // close .card } sb.Append("
"); return sb.ToString(); } /// Returns campaign status as an anonymous object (serialized to JSON by Controller). public static object GetStatus() { var progress = LoadProgress(); if (progress == null) return new { started = false }; var synthesized = progress.Batches.Count(b => b.Synthesized); var totalReviewed = Math.Min(synthesized * BatchSize, AllAlarmKeys.Length); var current = progress.Batches.LastOrDefault(); var totalBatches = (int)Math.Ceiling((double)AllAlarmKeys.Length / BatchSize); return new { started = true, startedAt = progress.StartedAt, totalAlarms = AllAlarmKeys.Length, reviewedAlarms = totalReviewed, percentComplete = Math.Round((double)totalReviewed / AllAlarmKeys.Length * 100, 1), completedBatches = synthesized, totalBatches, currentBatch = current == null ? null : (object)new { batchNumber = current.BatchNumber, sentDate = current.SentDate, synthesized = current.Synthesized, resendCount = current.ResendCount, submissions = current.Submissions.ToDictionary( kv => kv.Key, kv => kv.Value != null ? (object)new { submitted = true, at = kv.Value.SubmittedAt } : null), }, }; } /// Returns an HTML correction form pre-filled with the current synthesized content for a specific alarm. public static string GetCorrectionPage(int batchNumber, string alarmKey) { var progress = LoadProgress(); var batch = progress?.Batches.FirstOrDefault(b => b.BatchNumber == batchNumber); if (batch == null || !batch.ImprovedEntries.TryGetValue(alarmKey, out var entry)) return "

Alarm not found.

"; var label = GermanName(alarmKey); var causesJson = Newtonsoft.Json.JsonConvert.SerializeObject(entry.Causes); var stepsJson = Newtonsoft.Json.JsonConvert.SerializeObject(entry.NextSteps); var submitUrl = $"{BaseUrl}/ApplyAlarmCorrection"; return $$""" Korrektur — {{System.Web.HttpUtility.HtmlEncode(label)}}
Alarm-Korrektur
{{System.Web.HttpUtility.HtmlEncode(label)}}
"""; } /// Applies an admin correction to a specific alarm entry and regenerates the checked file. public static bool ApplyCorrection(int batchNumber, string alarmKey, DiagnosticResponse correction) { if (string.IsNullOrWhiteSpace(correction.Explanation) || correction.Causes == null || correction.Causes.Count == 0 || correction.NextSteps == null || correction.NextSteps.Count == 0) return false; lock (_fileLock) { var progress = LoadProgress(); var batch = progress?.Batches.FirstOrDefault(b => b.BatchNumber == batchNumber); if (batch == null) return false; batch.ImprovedEntries[alarmKey] = correction; SaveProgress(progress!); } // Regenerate outside the lock — reads progress fresh var prog = LoadProgress(); if (prog != null) RegenerateCheckedFile(prog); Console.WriteLine($"[AlarmReviewService] Admin correction applied for {alarmKey} (batch {batchNumber})."); return true; } /// Returns the generated AlarmTranslationsChecked.de.json content for download. public static string? GetCheckedFileContent() { if (!File.Exists(CheckedFilePath)) return null; return File.ReadAllText(CheckedFilePath); } // ── Synthesis ────────────────────────────────────────────────────────────── private static async Task SynthesizeBatchAsync(BatchRecord batch, AlarmReviewProgress progress) { if (_synthesizing || batch.Synthesized) return; _synthesizing = true; try { Console.WriteLine($"[AlarmReviewService] Synthesizing batch {batch.BatchNumber}..."); var submissions = batch.Submissions.Values.Where(s => s != null).Select(s => s!).ToList(); if (submissions.Count == 0) { Console.WriteLine($"[AlarmReviewService] Batch {batch.BatchNumber}: No submissions — skipping synthesis."); return; } for (int i = 0; i < batch.AlarmKeys.Count; i++) { var alarmKey = batch.AlarmKeys[i]; var original = DiagnosticService.TryGetTranslation(alarmKey, "de") ?? AlarmKnowledgeBase.TryGetDiagnosis(alarmKey); if (original == null) continue; var feedbacks = submissions .Where(s => i < s.Feedbacks.Count) .Select(s => s.Feedbacks[i]) .ToList(); var anyChanges = feedbacks.Any(f => !f.ExplanationOk || !f.CausesOk || !f.StepsOk); if (!anyChanges) { 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; batch.SynthesizedAt = DateTime.UtcNow.ToString("O"); SaveProgress(progress); RegenerateCheckedFile(progress); var improved2 = batch.ImprovedEntries.Count(kv => { var orig = AlarmKnowledgeBase.TryGetDiagnosis(kv.Key); return orig != null && kv.Value.Explanation != orig.Explanation; }); Console.WriteLine($"[AlarmReviewService] Batch {batch.BatchNumber} synthesized. ~{improved2} alarms changed by AI."); 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 { _synthesizing = false; } } // ── Mistral synthesis call ───────────────────────────────────────────────── private const string MistralUrl = "https://api.mistral.ai/v1/chat/completions"; private static async Task CallMistralForSynthesisAsync( string alarmKey, DiagnosticResponse original, List feedbacks) { var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY"); if (string.IsNullOrWhiteSpace(apiKey)) return null; var deviceType = SinexcelKeySet.Contains(alarmKey) ? "Sinexcel" : "Growatt"; var prompt = BuildSynthesisPrompt(alarmKey, deviceType, original, feedbacks); try { var requestBody = new { model = "mistral-large-latest", messages = new[] { new { role = "user", content = prompt } }, max_tokens = 600, temperature = 0.1, }; var responseText = await MistralUrl .WithHeader("Authorization", $"Bearer {apiKey}") .PostJsonAsync(requestBody) .ReceiveString(); var envelope = JsonConvert.DeserializeObject(responseText); var content = (string?)envelope?.choices?[0]?.message?.content; if (string.IsNullOrWhiteSpace(content)) return null; var json = content.Trim(); if (json.StartsWith("```")) { var nl = json.IndexOf('\n'); if (nl >= 0) json = json[(nl + 1)..]; if (json.EndsWith("```")) json = json[..^3]; json = json.Trim(); } var result = JsonConvert.DeserializeObject(json); return ValidateSynthesisOutput(result, feedbacks, alarmKey); } catch (Exception ex) { Console.Error.WriteLine($"[AlarmReviewService] Mistral failed for {alarmKey}: {ex.Message}"); return null; } } private static string BuildSynthesisPrompt( string alarmKey, string deviceType, DiagnosticResponse original, List feedbacks) { var sb = new StringBuilder(); sb.AppendLine("You are a documentation specialist for residential solar+battery systems (Sinexcel/Growatt inverters)."); sb.AppendLine("Synthesize the ORIGINAL alarm content with REVIEWER FEEDBACK to produce an IMPROVED version."); sb.AppendLine(); sb.AppendLine($"Alarm: {SplitCamelCase(alarmKey)} ({deviceType})"); sb.AppendLine(); sb.AppendLine("ORIGINAL:"); sb.AppendLine($" Explanation: {original.Explanation}"); sb.AppendLine(" Causes:"); foreach (var c in original.Causes) sb.AppendLine($" - {c}"); sb.AppendLine(" Next Steps:"); foreach (var s in original.NextSteps) sb.AppendLine($" - {s}"); sb.AppendLine(); sb.AppendLine($"REVIEWER FEEDBACK ({feedbacks.Count} reviewer(s)):"); for (int i = 0; i < feedbacks.Count; i++) { var f = feedbacks[i]; sb.AppendLine($" Reviewer {i + 1}:"); sb.AppendLine($" Explanation: {(f.ExplanationOk ? "Approved as-is" : $"Changed to: \"{f.Explanation}\"")}"); if (!f.CausesOk) { sb.AppendLine(" Causes: Changed to:"); foreach (var c in f.Causes) sb.AppendLine($" - {c}"); } else sb.AppendLine(" Causes: Approved as-is"); if (!f.StepsOk) { sb.AppendLine(" Next Steps: Changed to:"); foreach (var s in f.NextSteps) sb.AppendLine($" - {s}"); } else sb.AppendLine(" Next Steps: Approved as-is"); if (!string.IsNullOrWhiteSpace(f.Comment)) sb.AppendLine($" Notes: {f.Comment}"); } sb.AppendLine(); sb.AppendLine("SYNTHESIS RULES:"); 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\":[\"...\"]}"); return sb.ToString(); } /// /// Validates AI synthesis output. Returns null (reject) if output is structurally bad /// so the caller can fall back to the reviewer's direct edit instead. /// private static DiagnosticResponse? ValidateSynthesisOutput( DiagnosticResponse? result, List feedbacks, string alarmKey) { if (result == null) return null; // Must have non-empty content if (string.IsNullOrWhiteSpace(result.Explanation)) { Console.Error.WriteLine($"[AlarmReviewService] Validation failed for {alarmKey}: empty explanation."); return null; } if (result.Causes == null || result.Causes.Count == 0) { Console.Error.WriteLine($"[AlarmReviewService] Validation failed for {alarmKey}: empty causes."); return null; } if (result.NextSteps == null || result.NextSteps.Count == 0) { Console.Error.WriteLine($"[AlarmReviewService] Validation failed for {alarmKey}: empty nextSteps."); return null; } // If reviewer changed causes, output must have the exact same count var causesFeedback = feedbacks.FirstOrDefault(f => !f.CausesOk); if (causesFeedback != null && result.Causes.Count != causesFeedback.Causes.Count) { Console.Error.WriteLine($"[AlarmReviewService] Validation failed for {alarmKey}: causes count mismatch " + $"(expected {causesFeedback.Causes.Count}, got {result.Causes.Count})."); return null; } // If reviewer changed steps, output must have the exact same count var stepsFeedback = feedbacks.FirstOrDefault(f => !f.StepsOk); if (stepsFeedback != null && result.NextSteps.Count != stepsFeedback.NextSteps.Count) { Console.Error.WriteLine($"[AlarmReviewService] Validation failed for {alarmKey}: nextSteps count mismatch " + $"(expected {stepsFeedback.NextSteps.Count}, got {result.NextSteps.Count})."); return null; } return result; } /// /// Builds a DiagnosticResponse from the reviewer's direct edits (majority vote per section). /// Used when AI output is rejected by validation. /// private static DiagnosticResponse BuildReviewerDirectEdit( DiagnosticResponse original, List feedbacks) { // For explanation: use first reviewer who changed it, else keep original var expFeedback = feedbacks.FirstOrDefault(f => !f.ExplanationOk); var explanation = expFeedback != null ? expFeedback.Explanation : original.Explanation; // For causes: use first reviewer who changed it, else keep original var causesFeedback = feedbacks.FirstOrDefault(f => !f.CausesOk); var causes = causesFeedback != null ? causesFeedback.Causes : original.Causes; // For steps: use first reviewer who changed it, else keep original var stepsFeedback = feedbacks.FirstOrDefault(f => !f.StepsOk); var steps = stepsFeedback != null ? stepsFeedback.NextSteps : original.NextSteps; return new DiagnosticResponse { Explanation = explanation, Causes = causes, NextSteps = steps, }; } // ── AlarmTranslationsChecked.de.json generation ─────────────────────────── private static void RegenerateCheckedFile(AlarmReviewProgress progress) { // Collect all improved entries across all synthesized batches var improved = new Dictionary(); foreach (var batch in progress.Batches.Where(b => b.Synthesized)) foreach (var kv in batch.ImprovedEntries) improved[kv.Key] = kv.Value; // Build the JSON dict: alarmKey → {Explanation, Causes, NextSteps} // For unreviewed alarms fall back to the existing German translation var output = new Dictionary(); foreach (var key in AllAlarmKeys) { var entry = improved.TryGetValue(key, out var imp) ? imp : DiagnosticService.TryGetTranslation(key, "de") ?? AlarmKnowledgeBase.TryGetDiagnosis(key); if (entry == null) continue; output[key] = new { entry.Explanation, entry.Causes, entry.NextSteps }; } var json = JsonConvert.SerializeObject(output, Formatting.Indented); var totalReviewed = Math.Min(progress.Batches.Count(b => b.Synthesized) * BatchSize, AllAlarmKeys.Length); Directory.CreateDirectory(ResourcesDir); File.WriteAllText(CheckedFilePath, json, System.Text.Encoding.UTF8); Console.WriteLine($"[AlarmReviewService] AlarmTranslationsChecked.de.json written ({totalReviewed}/{AllAlarmKeys.Length} reviewed)."); } private static void AppendEntry(StringBuilder sb, string key, Dictionary improved) { var isReviewed = improved.ContainsKey(key); var entry = isReviewed ? improved[key] : AlarmKnowledgeBase.TryGetDiagnosis(key); if (entry == null) { Console.Error.WriteLine($"[AlarmReviewService] Warning: no entry found for key '{key}' — skipping."); return; } sb.AppendLine($" [\"{key}\"] = new()"); sb.AppendLine(" {"); sb.AppendLine($" Explanation = \"{EscapeForCSharp(entry.Explanation)}\","); sb.AppendLine(" Causes = new[]"); sb.AppendLine(" {"); foreach (var c in entry.Causes) sb.AppendLine($" \"{EscapeForCSharp(c)}\","); sb.AppendLine(" },"); sb.AppendLine(" NextSteps = new[]"); sb.AppendLine(" {"); foreach (var s in entry.NextSteps) sb.AppendLine($" \"{EscapeForCSharp(s)}\","); sb.AppendLine(" }"); sb.AppendLine(" },"); } // ── HTML review page ─────────────────────────────────────────────────────── private static string BuildReviewPage(BatchRecord batch, string reviewerName) { var alarmData = batch.AlarmKeys.Select(key => { var diag = AlarmKnowledgeBase.TryGetDiagnosis(key); return new { key, deviceType = SinexcelKeySet.Contains(key) ? "Sinexcel" : "Growatt", displayName = GermanName(key), explanation = DiagnosticService.TryGetTranslation(key, "de")?.Explanation ?? diag?.Explanation ?? "", causes = (DiagnosticService.TryGetTranslation(key, "de")?.Causes ?? diag?.Causes)?.ToList() ?? new List(), nextSteps = (DiagnosticService.TryGetTranslation(key, "de")?.NextSteps ?? diag?.NextSteps)?.ToList() ?? new List(), }; }); var alarmsJson = JsonConvert.SerializeObject(alarmData); var submitUrl = $"{BaseUrl}/SubmitAlarmReview?batch={batch.BatchNumber}&reviewer={Uri.EscapeDataString(reviewerName)}"; // Include a content hash in the key so stale localStorage is auto-invalidated when translations change var contentHash = (Math.Abs(alarmsJson.GetHashCode()) % 100000).ToString(); var lsKey = $"ar-b{batch.BatchNumber}-{reviewerName.ToLowerInvariant()}-{contentHash}"; var total = batch.AlarmKeys.Count; var quote = DailyQuotes[(Math.Max(0, batch.BatchNumber - 1)) % DailyQuotes.Length]; // Use string.Replace placeholders to avoid C# vs JS brace conflicts var html = HtmlTemplate .Replace("%%REVIEWER%%", reviewerName) .Replace("%%BATCH%%", batch.BatchNumber.ToString()) .Replace("%%TOTAL%%", total.ToString()) .Replace("%%ALARMS_JSON%%", alarmsJson) .Replace("%%SUBMIT_URL%%", submitUrl) .Replace("%%LS_KEY%%", lsKey) .Replace("%%QUOTE%%", quote); return html; } // The HTML template uses %%PLACEHOLDER%% for C# injection, avoids escaping conflicts private const string HtmlTemplate = """ Alarmprüfung · Stapel %%BATCH%%
Alarmwissensdatenbank – Überprüfung
Hallo %%REVIEWER%%  ·  Stapel %%BATCH%%
%%QUOTE%%
Vielen Dank für Ihre Zeit — Ihr Beitrag macht einen echten Unterschied für unsere Kunden. 🙏
inesco Energy Monitor
"""; // ── Email builders ───────────────────────────────────────────────────────── private static readonly string[] DailyQuotes = { "Jeder Alarm, den Sie verbessern, hilft einem echten Hausbesitzer, ruhiger zu schlafen. ⚡", "Sie sind die letzte Verteidigungslinie zwischen verwirrendem Fachjargon und einem besorgten Kunden. 🛡️", "Eine klare Erklärung heute erspart morgen einen nächtlichen Support-Anruf. 🌙", "Gute Dokumentation ist ein Akt der Freundlichkeit gegenüber jemandem, den man nie treffen wird. 🤝", "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 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. 🌍", "Großartige Arbeit sieht nicht immer dramatisch aus. Manchmal sieht sie so aus wie 'Passt so' zu klicken. ✅", "Eine gut geschriebene Schritt-Liste ist tausend Support-E-Mails wert. 📋", "Sie machen die Plattform für alle besser. Das zählt. 🌱", "Fachliche Genauigkeit + einfache Sprache = ein zufriedener Kunde. Sie sind die Brücke. 🌉", "Auch Roboter brauchen Menschen, die ihre Hausaufgaben prüfen. Danke, dass Sie dieser Mensch sind. 🤖", "Ihr Fachwissen von heute wird morgen die Sicherheit eines anderen. ☀️", "Gute Alarmmeldungen informieren nicht nur — sie beruhigen. Sie kennen den Unterschied. 🎯", "Jede Änderung, die Sie vornehmen, ist ein kleiner Sieg der Klarheit über die Verwirrung. 🏆", "Irgendwo arbeitet eine Solarbatterie still vor sich hin. Ihre Arbeit hilft zu erklären, wenn das nicht so ist. 🔋", "Das Support-Team der Zukunft wird dankbar sein, dass es diesen Alarm nicht erklären muss. 😄", "Sie können Alarme nicht verhindern — aber Sie können dafür sorgen, dass die Leute sie verstehen. 💬", "Diese Kampagne endet in ca. 23 Tagen. Die verbesserte Wissensdatenbank wird viel länger bestehen. 📚", }; private static string BuildReviewerEmailHtml(string name, string reviewUrl, int batchNum, int alarmCount, string quote, bool isResend) { var urgentBanner = isResend ? """
⚠️ Noch keine Rückmeldungen eingegangen. Dieselben Alarme werden erneut gesendet. Bitte bis 8:00 Uhr morgen früh einreichen.
""" : ""; return $"""
Alarmwissensdatenbank – Überprüfung
Stapel {batchNum} · {alarmCount} Alarme
{urgentBanner}

Hallo {name},

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

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

{quote}

Vielen Dank für Ihre Zeit — Ihr Beitrag macht einen echten Unterschied für unsere Kunden. 🙏

inesco Energy Monitor
"""; } private static async Task SendBatchEmailsAsync(BatchRecord batch, bool isResend) { var quote = DailyQuotes[(batch.BatchNumber - 1) % DailyQuotes.Length]; foreach (var (name, email) in Reviewers) { var reviewUrl = $"{BaseUrl}/ReviewAlarms?batch={batch.BatchNumber}&reviewer={Uri.EscapeDataString(name)}"; var subject = isResend ? $"[Erneut gesendet] Alarmprüfung Stapel {batch.BatchNumber} — Keine Rückmeldungen" : $"Alarmprüfung · Stapel {batch.BatchNumber} von {(int)Math.Ceiling((double)AllAlarmKeys.Length / BatchSize)} — Bitte heute prüfen"; var html = BuildReviewerEmailHtml(name, reviewUrl, batch.BatchNumber, batch.AlarmKeys.Count, quote, isResend); await SendEmailAsync(email, subject, html); } } 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:00 Uhr morgen früh abschließen"; var html = $"""

Hallo {name},

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

Überprüfung abschließen →

inesco Energy Monitor

"""; await SendEmailAsync(email, subject, html); } private static async Task SendAdminDailySummaryAsync(BatchRecord batch, int totalReviewed) { var submitted = batch.Submissions.Where(kv => kv.Value != null).Select(kv => kv.Key).ToList(); var totalBatches = (int)Math.Ceiling((double)AllAlarmKeys.Length / BatchSize); var pct = Math.Round((double)totalReviewed / AllAlarmKeys.Length * 100, 1); var subject = $"[Alarm Review] Batch {batch.BatchNumber}/{totalBatches} synthesized — {totalReviewed}/{AllAlarmKeys.Length} alarms done ({pct}%)"; // Build before/after section for each alarm in the batch var beforeAfterRows = new StringBuilder(); foreach (var key in batch.AlarmKeys) { 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 && (improved.Explanation != original?.Explanation || !improved.Causes.SequenceEqual(original?.Causes ?? Array.Empty()) || !improved.NextSteps.SequenceEqual(original?.NextSteps ?? Array.Empty())); var statusColor = changed ? "#e67e22" : "#27ae60"; var statusText = changed ? "KI aktualisiert" : "Unverändert"; var correctUrl = $"{BaseUrl}/CorrectAlarm?batch={batch.BatchNumber}&key={Uri.EscapeDataString(key)}"; beforeAfterRows.Append($""" {System.Web.HttpUtility.HtmlEncode(label)}  {statusText} {(changed ? $" ✏ Korrigieren" : "")} """); // Explanation var origExp = original?.Explanation ?? "(none)"; var newExp = improved?.Explanation ?? origExp; beforeAfterRows.Append($""" Vorher (Deutsch) Nachher (KI) {System.Web.HttpUtility.HtmlEncode(origExp)} {System.Web.HttpUtility.HtmlEncode(newExp)} """); // Causes var origCauses = original?.Causes ?? Array.Empty(); var newCauses = improved?.Causes ?? origCauses; beforeAfterRows.Append($"""
Ursachen
{string.Join("", origCauses.Select(c => $"
• {System.Web.HttpUtility.HtmlEncode(c)}
"))}
Ursachen
{string.Join("", newCauses.Select(c => $"
• {System.Web.HttpUtility.HtmlEncode(c)}
"))} """); // Steps var origSteps = original?.NextSteps ?? Array.Empty(); var newSteps = improved?.NextSteps ?? origSteps; beforeAfterRows.Append($"""
Was zu tun ist
{string.Join("", origSteps.Select(s => $"
• {System.Web.HttpUtility.HtmlEncode(s)}
"))}
Was zu tun ist
{string.Join("", newSteps.Select(s => $"
• {System.Web.HttpUtility.HtmlEncode(s)}
"))} """); } var html = $"""

Batch {batch.BatchNumber} Synthesized

{DateTime.Now:yyyy-MM-dd HH:mm}

Reviewers responded {submitted.Count}/{Reviewers.Length} ({string.Join(", ", submitted)})
Overall progress {totalReviewed} / {AllAlarmKeys.Length} ({pct}%)

Vorher → Nachher

Rot = Original · Grün = synthetisiertes Ergebnis

{beforeAfterRows}

inesco Energy Monitor

"""; await SendEmailAsync(AdminEmail, subject, html); } private static async Task SendAdminStallAlertAsync(BatchRecord batch) { var subject = $"[Alarm Review] ⚠️ Batch {batch.BatchNumber} stalled — no responses (resend #{batch.ResendCount})"; var html = $"""

Alarm Review — Batch {batch.BatchNumber} Stalled

No reviewer has responded to Batch {batch.BatchNumber}. The batch has been resent (attempt #{batch.ResendCount}).

Alarms: {string.Join(", ", batch.AlarmKeys)}

"""; await SendEmailAsync(AdminEmail, subject, html); } private static async Task SendAdminCompletionEmailAsync(AlarmReviewProgress progress) { var subject = "✅ Alarm Review Campaign Complete — Ready for Cutover"; var html = $"""

✅ Alarm Review Campaign Complete

All 229 alarms have been reviewed and synthesized.

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

Next Steps

  1. On your local machine, pull the latest Resources/ folder from the server
  2. Run generate_alarm_translations.py — reads AlarmTranslationsChecked.de.json, generates en.json, fr.json, it.json
  3. Update DiagnosticService to load AlarmTranslations.en.json for English
  4. Deploy: cd csharp/App/Backend && ./deploy.sh

Campaign Summary

Started{progress.StartedAt[..10]}
Completed{DateTime.Now:yyyy-MM-dd}
Total batches{progress.Batches.Count}
Alarms reviewed229
"""; await SendEmailAsync(AdminEmail, subject, html); } // ── Email infrastructure ─────────────────────────────────────────────────── private static async Task SendEmailAsync(string toEmail, string subject, string htmlBody) { try { var config = await ReadMailerConfigAsync(); var msg = new MimeMessage(); msg.From.Add(new MailboxAddress(config.SenderName, config.SenderAddress)); msg.To.Add(new MailboxAddress(toEmail, toEmail)); msg.Subject = subject; msg.Body = new TextPart("html") { Text = htmlBody }; using var smtp = new SmtpClient(); await smtp.ConnectAsync(config.SmtpServerUrl, config.SmtpPort, SecureSocketOptions.StartTls); await smtp.AuthenticateAsync(config.SmtpUsername, config.SmtpPassword); await smtp.SendAsync(msg); await smtp.DisconnectAsync(true); Console.WriteLine($"[AlarmReviewService] Email sent → {toEmail}: {subject}"); } catch (Exception ex) { Console.Error.WriteLine($"[AlarmReviewService] Email failed → {toEmail}: {ex.Message}"); } } private static async Task ReadMailerConfigAsync() { await using var fs = File.OpenRead(MailerConfig.DefaultFile); var config = await System.Text.Json.JsonSerializer.DeserializeAsync(fs); return config ?? throw new InvalidOperationException("Failed to read MailerConfig.json"); } // ── Helpers ──────────────────────────────────────────────────────────────── private static string SplitCamelCase(string name) => Regex.Replace(name, @"(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", " ").Trim(); 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); } }