Innovenergy_trunk/csharp/App/Backend/Services/AlarmReviewService.cs

1453 lines
76 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<BatchRecord> 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<string> 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<string, ReviewerSubmission?> Submissions { get; set; } = new();
[JsonProperty("improvedEntries")] public Dictionary<string, DiagnosticResponse> ImprovedEntries{ get; set; } = new();
}
public class ReviewerSubmission
{
[JsonProperty("submittedAt")] public string SubmittedAt { get; set; } = "";
[JsonProperty("feedbacks")] public List<ReviewFeedback> 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<string> Causes { get; set; } = new();
[JsonProperty("stepsOk")] public bool StepsOk { get; set; }
[JsonProperty("nextSteps")] public List<string> 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<string, string> _germanNames = new Dictionary<string, string>();
public static void LoadGermanNames()
{
var file = Path.Combine(AppContext.BaseDirectory, "Resources", "AlarmNames.de.json");
if (!File.Exists(file)) return;
try
{
var raw = JsonConvert.DeserializeObject<Dictionary<string, string>>(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<string> 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).");
}
/// <summary>Stops the scheduler and deletes the progress file. Safe to call at any time.</summary>
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<AlarmReviewProgress>(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) ─────────────────────────────────────
/// <summary>Starts the campaign: creates progress file and sends Batch 1.</summary>
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();
}
/// <summary>
/// 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.
/// </summary>
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<string, ReviewerSubmission?> { ["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;
/// <summary>Returns the HTML review page for a given batch and reviewer.</summary>
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);
}
/// <summary>Saves reviewer feedback. Triggers synthesis if all 4 reviewers have submitted.</summary>
public static bool SubmitFeedback(int batchNumber, string? reviewerName, List<ReviewFeedback>? 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<string>();
f.NextSteps = f.NextSteps?.Select(s => s ?? "").ToList() ?? new List<string>();
}
// 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;
}
/// <summary>
/// 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.
/// </summary>
public static async Task<string> PreviewSynthesisAsync(List<ReviewFeedback>? feedbacks)
{
var testBatch = _testBatch;
if (testBatch == null)
return "<div class=\"card\" style=\"text-align:center;padding:32px\"><div style=\"font-size:32px\">⚠️</div><div style=\"font-weight:bold;margin:8px 0\">Test-Batch abgelaufen</div><div style=\"color:#888;font-size:13px\">Bitte erneut einen Test-E-Mail senden und nochmal versuchen.</div></div>";
if (feedbacks == null || feedbacks.Count != testBatch.AlarmKeys.Count || feedbacks.Any(f => f == null))
return "<div class=\"card\" style=\"text-align:center;padding:32px\"><div style=\"font-size:32px\">⚠️</div><div style=\"font-weight:bold;margin:8px 0\">Ungültige Eingabe</div></div>";
foreach (var f in feedbacks)
{
f.Explanation ??= "";
f.Comment ??= "";
f.Causes = f.Causes?.Select(c => c ?? "").ToList() ?? new List<string>();
f.NextSteps = f.NextSteps?.Select(s => s ?? "").ToList() ?? new List<string>();
}
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<ReviewFeedback> { fb });
return (key, original, synthesized, fb, anyChanges);
});
var results = await Task.WhenAll(synthTasks);
var sb = new StringBuilder();
sb.Append("<div style=\"padding:0 0 28px\">");
sb.Append("<div class=\"card\" style=\"background:#f0f7ff;border-left:4px solid #3498db\">");
sb.Append("<div style=\"font-size:16px;font-weight:bold;color:#2c3e50;margin-bottom:4px\">Synthese-Vorschau (Testlauf)</div>");
sb.Append("<div style=\"font-size:12px;color:#888\">Nichts wurde gespeichert. Hier sehen Sie, was die KI mit Ihren Änderungen synthetisieren würde:</div>");
sb.Append("</div>");
if (!mistralAvailable)
sb.Append("<div style=\"background:#fdf3e7;border-left:4px solid #e67e22;padding:10px 14px;border-radius:0 4px 4px 0;font-size:12px;margin:0 0 14px\">⚠️ <strong>Mistral API nicht konfiguriert</strong> — Es werden Ihre Änderungen angezeigt, ohne KI-Synthese.</div>");
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("<div class=\"card\">");
sb.Append($"<div class=\"alarm-hdr\"><span class=\"badge {badgeClass}\">{badgeName}</span><span class=\"alarm-name\">{System.Web.HttpUtility.HtmlEncode(label)}</span><span style=\"margin-left:auto;font-size:12px;font-weight:bold;color:{statusColor}\">{statusText}</span></div>");
if (!anyChanges)
{
sb.Append("<div style=\"color:#27ae60;font-size:13px;padding-bottom:4px\">✓ Alle Abschnitte als korrekt markiert — keine Änderungen.</div>");
}
else
{
sb.Append("<table style=\"width:100%;border-collapse:collapse\">");
sb.Append("<tr><td style=\"width:50%;padding:4px 8px 6px 0;font-size:11px;font-weight:bold;color:#888;text-transform:uppercase\">Vorher (Original)</td><td style=\"width:50%;padding:4px 0 6px;font-size:11px;font-weight:bold;color:#e67e22;text-transform:uppercase\">Nachher (Synthese)</td></tr>");
// Explanation
sb.Append($"<tr><td style=\"padding:4px 8px 10px 0;vertical-align:top;font-size:13px;color:#555\">{System.Web.HttpUtility.HtmlEncode(original.Explanation)}</td>");
sb.Append($"<td style=\"padding:4px 0 10px;vertical-align:top;font-size:13px;color:#27ae60\">{System.Web.HttpUtility.HtmlEncode(finalResult.Explanation)}</td></tr>");
// Causes
sb.Append("<tr><td style=\"padding:2px 8px 8px 0;vertical-align:top;font-size:12px;color:#555\">");
sb.Append("<div style=\"font-size:10px;color:#aaa;margin-bottom:3px\">URSACHEN</div>");
foreach (var c in original.Causes) sb.Append($"<div>• {System.Web.HttpUtility.HtmlEncode(c)}</div>");
sb.Append("</td><td style=\"padding:2px 0 8px;vertical-align:top;font-size:12px;color:#27ae60\">");
sb.Append("<div style=\"font-size:10px;color:#aaa;margin-bottom:3px\">URSACHEN</div>");
foreach (var c in finalResult.Causes) sb.Append($"<div>• {System.Web.HttpUtility.HtmlEncode(c)}</div>");
sb.Append("</td></tr>");
// Next Steps
sb.Append("<tr><td style=\"padding:2px 8px 8px 0;vertical-align:top;font-size:12px;color:#555\">");
sb.Append("<div style=\"font-size:10px;color:#aaa;margin-bottom:3px\">WAS ZU TUN IST</div>");
foreach (var s in original.NextSteps) sb.Append($"<div>• {System.Web.HttpUtility.HtmlEncode(s)}</div>");
sb.Append("</td><td style=\"padding:2px 0 8px;vertical-align:top;font-size:12px;color:#27ae60\">");
sb.Append("<div style=\"font-size:10px;color:#aaa;margin-bottom:3px\">WAS ZU TUN IST</div>");
foreach (var s in finalResult.NextSteps) sb.Append($"<div>• {System.Web.HttpUtility.HtmlEncode(s)}</div>");
sb.Append("</td></tr>");
sb.Append("</table>");
}
sb.Append("</div>"); // close .card
}
sb.Append("</div>");
return sb.ToString();
}
/// <summary>Returns campaign status as an anonymous object (serialized to JSON by Controller).</summary>
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),
},
};
}
/// <summary>Returns the generated AlarmKnowledgeBaseChecked.cs content for download.</summary>
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<DiagnosticResponse?> CallMistralForSynthesisAsync(
string alarmKey, DiagnosticResponse original, List<ReviewFeedback> 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<dynamic>(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<DiagnosticResponse>(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<ReviewFeedback> 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: 24 bullets, plain language");
sb.AppendLine("- Next Steps: 24 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<string, DiagnosticResponse>();
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<string, DiagnosticResponse> SinexcelAlarms =");
sb.AppendLine(" new Dictionary<string, DiagnosticResponse>");
sb.AppendLine(" {");
foreach (var key in SinexcelKeys)
AppendEntry(sb, key, improved);
sb.AppendLine(" };");
sb.AppendLine();
sb.AppendLine(" public static readonly IReadOnlyDictionary<string, DiagnosticResponse> GrowattAlarms =");
sb.AppendLine(" new Dictionary<string, DiagnosticResponse>");
sb.AppendLine(" {");
foreach (var key in GrowattKeys)
AppendEntry(sb, key, improved);
sb.AppendLine(" };");
sb.AppendLine("}");
File.WriteAllText(CheckedFilePath, sb.ToString());
Console.WriteLine($"[AlarmReviewService] AlarmKnowledgeBaseChecked.cs written ({Math.Min(totalReviewed, AllAlarmKeys.Length)}/{AllAlarmKeys.Length} reviewed).");
}
private static void AppendEntry(StringBuilder sb, string key, Dictionary<string, DiagnosticResponse> improved)
{
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<string>(),
nextSteps = (DiagnosticService.TryGetTranslation(key, "de")?.NextSteps ?? diag?.NextSteps)?.ToList() ?? new List<string>(),
};
});
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 = """
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Alarmprüfung · Stapel %%BATCH%%</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:Arial,Helvetica,sans-serif;background:#fce8ed;color:#333;padding:0 16px}
.hdr{background:#2c3e50;color:#fff;padding:14px 20px;position:sticky;top:0;z-index:10;margin:0 -16px}
.hdr-title{font-size:17px;font-weight:bold}
.hdr-sub{font-size:12px;opacity:.8;margin-top:3px}
.prog-bar{background:rgba(255,255,255,.2);border-radius:4px;height:5px;margin-top:8px}
.prog-fill{background:#27ae60;height:5px;border-radius:4px;transition:width .3s}
.card{background:#fff;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,.08);margin:18px 0;padding:22px}
.alarm-hdr{display:flex;align-items:center;gap:10px;margin-bottom:18px}
.badge{font-size:11px;font-weight:bold;padding:3px 8px;border-radius:12px}
.sin{background:#e8f4fd;color:#2980b9}
.gro{background:#e8f8f0;color:#27ae60}
.alarm-name{font-size:19px;font-weight:bold;color:#2c3e50}
.dots{display:flex;gap:5px;flex-wrap:wrap;justify-content:center;margin-bottom:16px}
.dot{width:10px;height:10px;border-radius:50%;background:#ddd;cursor:pointer;transition:all .15s}
.dot.vis{background:#3498db}
.dot.cur{background:#2c3e50;transform:scale(1.3)}
.dot.chg{background:#e67e22}
.sec{border:1px solid #eee;border-radius:6px;padding:13px;margin-bottom:12px}
.sec-lbl{font-size:10px;font-weight:bold;text-transform:uppercase;color:#888;letter-spacing:.5px;margin-bottom:8px}
.orig{font-size:13px;color:#555;line-height:1.6;background:#f8f9fa;padding:8px 12px;border-radius:4px;margin-bottom:9px}
.orig-list{font-size:13px;color:#555;background:#f8f9fa;padding:8px 12px 8px 28px;border-radius:4px;margin-bottom:9px;line-height:1.8}
.tog{display:flex;gap:8px;margin-bottom:9px}
.btn-ok,.btn-ed{border:1px solid #ddd;background:#fff;border-radius:20px;padding:5px 14px;font-size:12px;cursor:pointer;transition:all .15s}
.btn-ok.on{background:#27ae60;color:#fff;border-color:#27ae60}
.btn-ed.on{background:#e67e22;color:#fff;border-color:#e67e22}
textarea{width:100%;border:1px solid #ddd;border-radius:4px;padding:7px 9px;font-size:13px;font-family:inherit;resize:vertical;margin-top:5px}
textarea:focus{outline:none;border-color:#3498db}
.list-ed{margin-top:6px}
.li-row{display:flex;align-items:flex-start;gap:5px;margin-bottom:5px}
.li-row textarea{flex:1;min-height:36px;margin-top:0}
.btn-rm{background:#e74c3c;color:#fff;border:none;border-radius:4px;width:26px;height:26px;cursor:pointer;font-size:14px;flex-shrink:0;margin-top:3px}
.drag-handle{cursor:grab;font-size:18px;color:#ccc;flex-shrink:0;margin-top:5px;padding:0 3px;user-select:none;line-height:1}
.drag-handle:active{cursor:grabbing}
.li-row.drag-over{outline:2px dashed #3498db;border-radius:4px;background:#eaf4ff}
.btn-add{background:#3498db;color:#fff;border:none;border-radius:4px;padding:4px 11px;cursor:pointer;font-size:12px;margin-top:4px}
.comment{width:100%;border:1px solid #eee;border-radius:4px;padding:7px 9px;font-size:12px;font-family:inherit;resize:none;color:#aaa}
.comment:focus{outline:none;border-color:#3498db;color:#333}
.quote{background:#f0f7ff;border-radius:6px;padding:11px 16px;margin:14px 0 4px;font-size:13px;color:#555;font-style:italic}
.thankyou{font-size:12px;color:#888;text-align:center;padding:6px 0 20px}
.nav{display:flex;justify-content:space-between;align-items:center;margin:0 0 28px;padding:0 2px}
.nav button{padding:9px 22px;border-radius:6px;font-size:14px;cursor:pointer;border:none}
.btn-prev{background:#ecf0f1;color:#555}
.btn-prev:disabled{opacity:.4;cursor:default}
.btn-next{background:#3498db;color:#fff}
.btn-sub{background:#27ae60;color:#fff;font-weight:bold}
.success{background:#fff;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,.08);margin:50px auto;max-width:440px;padding:40px;text-align:center}
.si{font-size:48px;margin-bottom:14px}
.st{font-size:21px;font-weight:bold;color:#27ae60;margin-bottom:8px}
.ss{font-size:13px;color:#888}
</style>
</head>
<body>
<div class="hdr">
<div class="hdr-title">Alarmwissensdatenbank Überprüfung</div>
<div class="hdr-sub">Hallo %%REVIEWER%% &nbsp;·&nbsp; Stapel %%BATCH%%</div>
<div class="prog-bar"><div class="prog-fill" id="pf" style="width:0%"></div></div>
</div>
<div class="quote">%%QUOTE%%</div>
<div id="app"></div>
<div class="nav" id="nav"></div>
<div class="thankyou">Vielen Dank für Ihre Zeit Ihr Beitrag macht einen echten Unterschied für unsere Kunden. 🙏</div>
<div style="text-align:center;padding-bottom:24px;font-size:11px;color:#bbb">inesco Energy Monitor</div>
<script>
var ALARMS = %%ALARMS_JSON%%;
var SUBMIT_URL = "%%SUBMIT_URL%%";
var LS_KEY = "%%LS_KEY%%";
var TOTAL = %%TOTAL%%;
var REVIEWER = "%%REVIEWER%%";
var reviews = (function(){
try { var s = localStorage.getItem(LS_KEY); if(s) return JSON.parse(s); } catch(e){}
return ALARMS.map(function(a){ return {
explanationOk:true, explanation:a.explanation,
causesOk:true, causes:a.causes.slice(),
stepsOk:true, nextSteps:a.nextSteps.slice(),
comment:'', visited:false
};});
})();
var cur = 0;
function save(){ localStorage.setItem(LS_KEY, JSON.stringify(reviews)); updateProg(); }
function updateProg(){
var n = reviews.filter(function(r){ return r.visited; }).length;
document.getElementById('pf').style.width = (n/TOTAL*100)+'%';
}
function esc(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function dots(){
return reviews.map(function(r,i){
var c='dot';
if(i===cur) c+=' cur';
else if(r.visited&&(!r.explanationOk||!r.causesOk||!r.stepsOk)) c+=' chg';
else if(r.visited) c+=' vis';
return '<div class="'+c+'" onclick="go('+i+')" title="Alarm '+(i+1)+'"></div>';
}).join('');
}
var _dragType=null, _dragIdx=null;
function listEditor(type, items){
var rows = items.map(function(item,i){
return '<div class="li-row" id="'+type+'-'+i+'" draggable="true"'+
' ondragstart="dragStart(\''+type+'\','+i+')"'+
' ondragover="dragOver(event)"'+
' ondragleave="dragLeave(event)"'+
' ondrop="dragDrop(event,\''+type+'\','+i+')">'+
'<span class="drag-handle" title="Ziehen zum Verschieben">&#9776;</span>'+
'<textarea oninput="updLi(\''+type+'\','+i+',this.value)">'+esc(item)+'</textarea>'+
'<button class="btn-rm" onclick="rmLi(\''+type+'\','+i+')">&#x2715;</button>'+
'</div>';
}).join('');
return '<div class="list-ed" id="'+type+'-ed">'+rows+'<button class="btn-add" onclick="addLi(\''+type+'\')">+ Hinzufügen</button></div>';
}
function render(){
var a = ALARMS[cur];
var r = reviews[cur];
r.visited = true;
save();
var bc = a.deviceType==='Sinexcel'?'sin':'gro';
var html =
'<div class="card">'+
'<div class="alarm-hdr">'+
'<span class="badge '+bc+'">'+a.deviceType+'</span>'+
'<span class="alarm-name">'+a.displayName+'</span>'+
'</div>'+
'<div class="dots">'+dots()+'</div>'+
'<div class="sec">'+
'<div class="sec-lbl">Erklärung</div>'+
'<div class="orig">'+esc(a.explanation)+'</div>'+
'<div class="tog">'+
'<button class="btn-ok'+(r.explanationOk?' on':'')+'" onclick="setOk(\'explanation\',true)"> Passt so</button>'+
'<button class="btn-ed'+(!r.explanationOk?' on':'')+'" onclick="setOk(\'explanation\',false)"> Bearbeiten</button>'+
'</div>'+
(!r.explanationOk?'<textarea rows="3" id="exp-edit" oninput="updExp(this.value)">'+esc(r.explanation)+'</textarea>':'')+
'</div>'+
'<div class="sec">'+
'<div class="sec-lbl">Mögliche Ursachen</div>'+
'<ul class="orig-list">'+a.causes.map(function(c){ return '<li>'+esc(c)+'</li>'; }).join('')+'</ul>'+
'<div class="tog">'+
'<button class="btn-ok'+(r.causesOk?' on':'')+'" onclick="setOk(\'causes\',true)"> Passt so</button>'+
'<button class="btn-ed'+(!r.causesOk?' on':'')+'" onclick="setOk(\'causes\',false)"> Bearbeiten</button>'+
'</div>'+
(!r.causesOk?listEditor('causes',r.causes):'')+
'</div>'+
'<div class="sec">'+
'<div class="sec-lbl">Was zu tun ist</div>'+
'<ul class="orig-list">'+a.nextSteps.map(function(s){ return '<li>'+esc(s)+'</li>'; }).join('')+'</ul>'+
'<div class="tog">'+
'<button class="btn-ok'+(r.stepsOk?' on':'')+'" onclick="setOk(\'steps\',true)"> Passt so</button>'+
'<button class="btn-ed'+(!r.stepsOk?' on':'')+'" onclick="setOk(\'steps\',false)"> Bearbeiten</button>'+
'</div>'+
(!r.stepsOk?listEditor('steps',r.nextSteps):'')+
'</div>'+
'<div class="sec">'+
'<div class="sec-lbl">Optionale Anmerkungen</div>'+
'<textarea class="comment" rows="2" placeholder="Zusätzliche Hinweise..." oninput="updCmt(this.value)">'+esc(r.comment)+'</textarea>'+
'</div>'+
'</div>';
document.getElementById('app').innerHTML = html;
var nav = document.getElementById('nav');
nav.innerHTML =
'<button class="btn-prev" onclick="go(cur-1)" '+(cur===0?'disabled':'')+'> Zurück</button>'+
(cur < TOTAL-1
? '<button class="btn-next" onclick="go(cur+1)">Weiter </button>'
: '<button class="btn-sub" onclick="submitAll()">Überprüfung einreichen </button>');
}
function go(i){ if(i<0||i>=TOTAL) return; cur=i; render(); }
function setOk(field,ok){
var r=reviews[cur];
if(field==='explanation'){r.explanationOk=ok; if(ok) r.explanation=ALARMS[cur].explanation;}
else if(field==='causes'){r.causesOk=ok; if(ok) r.causes=ALARMS[cur].causes.slice();}
else if(field==='steps'){r.stepsOk=ok; if(ok) r.nextSteps=ALARMS[cur].nextSteps.slice();}
save(); render();
}
function updExp(v){ reviews[cur].explanation=v; save(); }
function updCmt(v){ reviews[cur].comment=v; save(); }
function updLi(type,i,v){ var r=reviews[cur]; (type==='causes'?r.causes:r.nextSteps)[i]=v; save(); }
function rmLi(type,i){
var r=reviews[cur];
var arr=type==='causes'?r.causes:r.nextSteps;
arr.splice(i,1);
save(); render();
}
function addLi(type){
var r=reviews[cur];
(type==='causes'?r.causes:r.nextSteps).push('');
save(); render();
}
function dragStart(type,i){ _dragType=type; _dragIdx=i; }
function dragOver(e){ e.preventDefault(); e.currentTarget.classList.add('drag-over'); }
function dragLeave(e){ e.currentTarget.classList.remove('drag-over'); }
function dragDrop(e,type,i){
e.currentTarget.classList.remove('drag-over');
if(_dragType!==type||_dragIdx===null||_dragIdx===i){ _dragType=null; _dragIdx=null; return; }
var r=reviews[cur];
var arr=type==='causes'?r.causes:r.nextSteps;
var moved=arr.splice(_dragIdx,1)[0];
arr.splice(i,0,moved);
_dragType=null; _dragIdx=null;
save(); render();
}
function submitAll(){
var unvis = reviews.filter(function(r){ return !r.visited; }).length;
if(unvis>0){
alert('Bitte alle '+TOTAL+' Alarme besuchen, bevor Sie einreichen. '+unvis+' Alarm(e) noch nicht besucht.\n\nVerwenden Sie die Punkte oben zur Navigation.');
for(var i=0;i<reviews.length;i++){ if(!reviews[i].visited){ go(i); break; } }
return;
}
var feedbacks = reviews.map(function(r){
return {
explanationOk:r.explanationOk, explanation:r.explanation,
causesOk:r.causesOk, causes:r.causes,
stepsOk:r.stepsOk, nextSteps:r.nextSteps,
comment:r.comment
};
});
fetch(SUBMIT_URL,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(feedbacks)})
.then(function(res){ return res.ok?res.json():Promise.reject(res.statusText); })
.then(function(data){
localStorage.removeItem(LS_KEY);
document.getElementById('nav').innerHTML='';
document.getElementById('pf').style.width='100%';
if(data && data.preview){
document.getElementById('app').innerHTML = data.preview;
} else {
document.getElementById('app').innerHTML =
'<div class="success"><div class="si"></div>'+
'<div class="st">Überprüfung eingereicht!</div>'+
'<div class="ss">Vielen Dank, '+REVIEWER+'. Ihr Feedback wurde gespeichert.<br>Sie können dieses Fenster schließen.</div></div>';
}
})
.catch(function(err){ alert('Einreichen fehlgeschlagen: '+err+'\nBitte erneut versuchen.'); });
}
render();
</script>
</body>
</html>
""";
// ── 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
? """<div style="background:#fdf3e7;border-left:4px solid #e67e22;padding:12px 16px;border-radius:0 4px 4px 0;margin-bottom:16px;font-size:13px">⚠️ <strong>Noch keine Rückmeldungen eingegangen.</strong> Dieselben Alarme werden erneut gesendet. Bitte bis 8 Uhr morgen früh einreichen.</div>"""
: "";
return $"""
<!DOCTYPE html><html><head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#f4f4f4;font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#333">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f4;padding:20px 0">
<tr><td align="center">
<table width="540" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.08)">
<tr><td style="background:#2c3e50;padding:20px 28px;color:#fff">
<div style="font-size:17px;font-weight:bold">Alarmwissensdatenbank Überprüfung</div>
<div style="font-size:12px;opacity:.7;margin-top:4px">Stapel {batchNum} · {alarmCount} Alarme</div>
</td></tr>
<tr><td style="background:#fce8ed;padding:22px 28px">
{urgentBanner}
<p style="margin-bottom:13px">Hallo <strong>{name}</strong>,</p>
<p style="margin-bottom:13px">Bitte überprüfen Sie heute die <strong>{alarmCount} Alarmbeschreibungen</strong> und markieren Sie jede als <em>'Passt so'</em> oder bearbeiten Sie sie, um sie zu verbessern. Dies dauert etwa <strong>1520 Minuten</strong>.</p>
<p style="margin-bottom:18px;font-size:12px;color:#888"> Bitte bis <strong>8:00 Uhr morgen früh</strong> abschließen.</p>
<div style="text-align:center;margin-bottom:22px">
<a href="{reviewUrl}" style="background:#27ae60;color:#fff;text-decoration:none;padding:11px 30px;border-radius:6px;font-size:14px;font-weight:bold;display:inline-block">Überprüfung starten </a>
</div>
<div style="background:#f0f7ff;border-radius:6px;padding:12px 16px;margin-bottom:16px;font-size:13px;color:#555;font-style:italic">
{quote}
</div>
<p style="margin-bottom:0;font-size:13px;color:#555">Vielen Dank für Ihre Zeit Ihr Beitrag macht einen echten Unterschied für unsere Kunden. 🙏</p>
</td></tr>
<tr><td style="background:#ecf0f1;padding:12px 28px;text-align:center;font-size:11px;color:#888;border-top:1px solid #ddd">
inesco Energy Monitor
</td></tr>
</table></td></tr></table></body></html>
""";
}
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 = $"""
<!DOCTYPE html><html><head><meta charset="utf-8"></head>
<body style="font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#333;padding:24px;max-width:500px">
<p>Hallo <strong>{name}</strong>,</p>
<p style="margin-top:12px">Kurze Erinnerung die heutige Alarmprüfung <strong>(Stapel {batch.BatchNumber})</strong> schließt um <strong>8 Uhr morgen früh</strong>. Es dauert nur 15 Minuten!</p>
<p style="margin:20px 0"><a href="{reviewUrl}" style="background:#3498db;color:#fff;text-decoration:none;padding:10px 24px;border-radius:6px;font-size:13px;display:inline-block">Überprüfung abschließen </a></p>
<p style="font-size:11px;color:#bbb">inesco Energy Monitor</p>
</body></html>
""";
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<string>()) ||
!improved.NextSteps.SequenceEqual(original?.NextSteps ?? Array.Empty<string>()));
var statusColor = changed ? "#e67e22" : "#27ae60";
var statusText = changed ? "Aktualisiert" : "Unverändert";
beforeAfterRows.Append($"""
<tr><td colspan="2" style="padding:14px 0 4px;font-weight:bold;font-size:14px;color:#2c3e50;border-top:2px solid #eee">
{label} &nbsp;<span style="font-size:11px;font-weight:normal;color:{statusColor};background:#f8f9fa;padding:2px 8px;border-radius:10px">{statusText}</span>
</td></tr>
""");
// Explanation
var origExp = original?.Explanation ?? "(none)";
var newExp = improved?.Explanation ?? origExp;
var expStyle = newExp != origExp ? "color:#c0392b;text-decoration:line-through" : "color:#555";
beforeAfterRows.Append($"""
<tr>
<td style="padding:4px 12px 4px 0;vertical-align:top;width:50%;font-size:12px;color:#888;font-style:italic">Vorher</td>
<td style="padding:4px 0;vertical-align:top;width:50%;font-size:12px;color:#888;font-style:italic">Nachher</td>
</tr>
<tr>
<td style="padding:2px 12px 8px 0;vertical-align:top;font-size:13px;{expStyle}">{System.Web.HttpUtility.HtmlEncode(origExp)}</td>
<td style="padding:2px 0 8px;vertical-align:top;font-size:13px;color:#27ae60">{System.Web.HttpUtility.HtmlEncode(newExp)}</td>
</tr>
""");
// Causes
var origCauses = original?.Causes ?? Array.Empty<string>();
var newCauses = improved?.Causes ?? origCauses;
beforeAfterRows.Append($"""
<tr>
<td style="padding:2px 12px 6px 0;vertical-align:top;font-size:12px">
<div style="font-size:11px;color:#888;margin-bottom:3px">Ursachen</div>
{string.Join("", origCauses.Select(c => $"<div style='font-size:12px;color:#555;padding-left:8px'>• {System.Web.HttpUtility.HtmlEncode(c)}</div>"))}
</td>
<td style="padding:2px 0 6px;vertical-align:top;font-size:12px">
<div style="font-size:11px;color:#888;margin-bottom:3px">Ursachen</div>
{string.Join("", newCauses.Select(c => $"<div style='font-size:12px;color:#27ae60;padding-left:8px'>• {System.Web.HttpUtility.HtmlEncode(c)}</div>"))}
</td>
</tr>
""");
// Steps
var origSteps = original?.NextSteps ?? Array.Empty<string>();
var newSteps = improved?.NextSteps ?? origSteps;
beforeAfterRows.Append($"""
<tr>
<td style="padding:2px 12px 10px 0;vertical-align:top;font-size:12px">
<div style="font-size:11px;color:#888;margin-bottom:3px">Was zu tun ist</div>
{string.Join("", origSteps.Select(s => $"<div style='font-size:12px;color:#555;padding-left:8px'>• {System.Web.HttpUtility.HtmlEncode(s)}</div>"))}
</td>
<td style="padding:2px 0 10px;vertical-align:top;font-size:12px">
<div style="font-size:11px;color:#888;margin-bottom:3px">Was zu tun ist</div>
{string.Join("", newSteps.Select(s => $"<div style='font-size:12px;color:#27ae60;padding-left:8px'>• {System.Web.HttpUtility.HtmlEncode(s)}</div>"))}
</td>
</tr>
""");
}
var html = $"""
<!DOCTYPE html><html><head><meta charset="utf-8"></head>
<body style="font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#333;padding:24px;max-width:700px">
<h2 style="color:#2c3e50;margin-bottom:6px">Batch {batch.BatchNumber} Synthesized</h2>
<p style="color:#888;margin-bottom:18px;font-size:12px">{DateTime.Now:yyyy-MM-dd HH:mm}</p>
<table style="border-collapse:collapse;width:100%;margin-bottom:24px">
<tr><td style="padding:8px 0;border-bottom:1px solid #eee;width:180px">Reviewers responded</td>
<td style="padding:8px 0;border-bottom:1px solid #eee;font-weight:bold">{submitted.Count}/{Reviewers.Length} ({string.Join(", ", submitted)})</td></tr>
<tr><td style="padding:8px 0;border-bottom:1px solid #eee">Overall progress</td>
<td style="padding:8px 0;border-bottom:1px solid #eee;font-weight:bold">{totalReviewed} / {AllAlarmKeys.Length} ({pct}%)</td></tr>
</table>
<h3 style="color:#2c3e50;margin-bottom:4px">Vorher Nachher</h3>
<p style="font-size:12px;color:#888;margin-bottom:12px">Rot = Original · Grün = synthetisiertes Ergebnis</p>
<table style="border-collapse:collapse;width:100%">
{beforeAfterRows}
</table>
<p style="margin-top:18px;font-size:11px;color:#bbb">inesco Energy Monitor</p>
</body></html>
""";
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 = $"""
<!DOCTYPE html><html><head><meta charset="utf-8"></head>
<body style="font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#333;padding:24px;max-width:500px">
<h3 style="color:#e67e22">Alarm Review Batch {batch.BatchNumber} Stalled</h3>
<p style="margin-top:12px">No reviewer has responded to Batch {batch.BatchNumber}. The batch has been resent (attempt #{batch.ResendCount}).</p>
<p style="margin-top:10px;font-size:12px;color:#888">Alarms: {string.Join(", ", batch.AlarmKeys)}</p>
</body></html>
""";
await SendEmailAsync(AdminEmail, subject, html);
}
private static async Task SendAdminCompletionEmailAsync(AlarmReviewProgress progress)
{
var subject = "✅ Alarm Review Campaign Complete — Ready for Cutover";
var html = $"""
<!DOCTYPE html><html><head><meta charset="utf-8"></head>
<body style="font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#333;padding:24px;max-width:560px">
<h2 style="color:#27ae60"> Alarm Review Campaign Complete</h2>
<p style="margin-top:12px">All <strong>229 alarms</strong> have been reviewed and synthesized.</p>
<p style="margin-top:8px"><strong>AlarmKnowledgeBaseChecked.cs</strong> is ready for cutover on the server.</p>
<h3 style="margin-top:22px;color:#2c3e50">Cutover Steps</h3>
<ol style="margin-top:10px;line-height:2.2;padding-left:20px">
<li>Download the checked file:<br><code>curl "{BaseUrl}/DownloadCheckedKnowledgeBase?authToken=YOUR_TOKEN" -o AlarmKnowledgeBaseChecked.cs</code></li>
<li>Move it to <code>csharp/App/Backend/Services/</code></li>
<li>Delete <code>AlarmKnowledgeBase.cs</code></li>
<li>Rename class <code>AlarmKnowledgeBaseChecked</code> <code>AlarmKnowledgeBase</code></li>
<li>Run: <code>dotnet build &amp;&amp; ./deploy.sh</code></li>
</ol>
<h3 style="margin-top:22px;color:#2c3e50">Campaign Summary</h3>
<table style="border-collapse:collapse;margin-top:8px">
<tr><td style="padding:5px 16px 5px 0">Started</td><td style="font-weight:bold">{progress.StartedAt[..10]}</td></tr>
<tr><td style="padding:5px 16px 5px 0">Completed</td><td style="font-weight:bold">{DateTime.Now:yyyy-MM-dd}</td></tr>
<tr><td style="padding:5px 16px 5px 0">Total batches</td><td style="font-weight:bold">{progress.Batches.Count}</td></tr>
<tr><td style="padding:5px 16px 5px 0">Alarms reviewed</td><td style="font-weight:bold">229</td></tr>
</table>
</body></html>
""";
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<MailerConfig> ReadMailerConfigAsync()
{
await using var fs = File.OpenRead(MailerConfig.DefaultFile);
var config = await System.Text.Json.JsonSerializer.DeserializeAsync<MailerConfig>(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");
}