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(); } 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; } = ""; } // ── 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(AppContext.BaseDirectory, "AlarmKnowledgeBaseChecked.cs"); // ── 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() { 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) { await SendNextBatchAsync(progress); } else { var submissionCount = current.Submissions.Values.Count(s => s != null); if (submissionCount == 0) { current.ResendCount++; SaveProgress(progress); Console.WriteLine($"[AlarmReviewService] Batch {current.BatchNumber}: 0 submissions — resending (attempt #{current.ResendCount})."); await SendBatchEmailsAsync(current, isResend: true); await SendAdminStallAlertAsync(current); } else { await SynthesizeBatchAsync(current, progress); await SendNextBatchAsync(progress); } } } private static async Task SendNextBatchAsync(AlarmReviewProgress progress) { 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() { 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 = 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 the generated AlarmKnowledgeBaseChecked.cs 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 = 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); 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 { Explanation = original.Explanation, Causes = original.Causes, NextSteps = original.NextSteps, }; } 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)); } 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-small-latest", messages = new[] { new { role = "user", content = prompt } }, max_tokens = 500, temperature = 0.2, }; 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(); } return JsonConvert.DeserializeObject(json); } 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("- 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(); sb.AppendLine("Reply with ONLY valid JSON, no markdown fences:"); sb.AppendLine("{\"explanation\":\"...\",\"causes\":[\"...\"],\"nextSteps\":[\"...\"]}"); return sb.ToString(); } // ── AlarmKnowledgeBaseChecked.cs 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; var totalReviewed = progress.Batches.Count(b => b.Synthesized) * BatchSize; var lastUpdated = DateTime.Now.ToString("yyyy-MM-dd HH:mm"); var sb = new StringBuilder(); sb.AppendLine("// AUTO-GENERATED by AlarmReviewService — DO NOT EDIT MANUALLY"); sb.AppendLine($"// Progress: {Math.Min(totalReviewed, AllAlarmKeys.Length)} / {AllAlarmKeys.Length} reviewed | Last updated: {lastUpdated}"); sb.AppendLine(); sb.AppendLine("namespace InnovEnergy.App.Backend.Services;"); sb.AppendLine(); sb.AppendLine("public static class AlarmKnowledgeBaseChecked"); sb.AppendLine("{"); sb.AppendLine(" public static readonly IReadOnlyDictionary SinexcelAlarms ="); sb.AppendLine(" new Dictionary"); sb.AppendLine(" {"); foreach (var key in SinexcelKeys) AppendEntry(sb, key, improved); sb.AppendLine(" };"); sb.AppendLine(); sb.AppendLine(" public static readonly IReadOnlyDictionary GrowattAlarms ="); sb.AppendLine(" new Dictionary"); sb.AppendLine(" {"); foreach (var key in GrowattKeys) AppendEntry(sb, key, improved); sb.AppendLine(" };"); sb.AppendLine("}"); File.WriteAllText(CheckedFilePath, sb.ToString()); Console.WriteLine($"[AlarmReviewService] AlarmKnowledgeBaseChecked.cs written ({Math.Min(totalReviewed, AllAlarmKeys.Length)}/{AllAlarmKeys.Length} reviewed)."); } private static void AppendEntry(StringBuilder sb, string key, Dictionary 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 15 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 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 15–20 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 Uhr morgen abschließen"; var html = $"""

Hallo {name},

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

Ü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 = 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 ? "Aktualisiert" : "Unverändert"; beforeAfterRows.Append($""" {label}  {statusText} """); // Explanation var origExp = original?.Explanation ?? "(none)"; var newExp = improved?.Explanation ?? origExp; var expStyle = newExp != origExp ? "color:#c0392b;text-decoration:line-through" : "color:#555"; beforeAfterRows.Append($""" Vorher Nachher {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.

AlarmKnowledgeBaseChecked.cs is ready for cutover on the server.

Cutover Steps

  1. Download the checked file:
    curl "{BaseUrl}/DownloadCheckedKnowledgeBase?authToken=YOUR_TOKEN" -o AlarmKnowledgeBaseChecked.cs
  2. Move it to csharp/App/Backend/Services/
  3. Delete AlarmKnowledgeBase.cs
  4. Rename class AlarmKnowledgeBaseCheckedAlarmKnowledgeBase
  5. Run: dotnet build && ./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"); }