1790 lines
94 KiB
C#
1790 lines
94 KiB
C#
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();
|
||
[JsonProperty("note")] public string? Note { get; set; }
|
||
}
|
||
|
||
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; } = "";
|
||
}
|
||
|
||
public class AlarmCorrectionRequest
|
||
{
|
||
[JsonProperty("batchNumber")] public int BatchNumber { get; set; }
|
||
[JsonProperty("alarmKey")] public string? AlarmKey { get; set; }
|
||
[JsonProperty("explanation")] public string? Explanation { get; set; }
|
||
[JsonProperty("causes")] public List<string>? Causes { get; set; }
|
||
[JsonProperty("nextSteps")] public List<string>? NextSteps { get; set; }
|
||
}
|
||
|
||
// ── Service ───────────────────────────────────────────────────────────────────
|
||
|
||
public static class AlarmReviewService
|
||
{
|
||
// ── Configuration ─────────────────────────────────────────────────────────
|
||
|
||
private static readonly (string Name, string Email)[] Reviewers =
|
||
{
|
||
("Rüdiger", "junghans@inesco.energy"),
|
||
("Nico", "lapp@inesco.energy"),
|
||
("Fabio", "niederberger@inesco.energy"),
|
||
("Jan", "dustmann@inesco.energy"),
|
||
};
|
||
|
||
private const string AdminEmail = "liu@inesco.energy";
|
||
private const string BaseUrl = "https://monitor.inesco.energy/api";
|
||
private const int BatchSize = 10;
|
||
|
||
// ── File paths ─────────────────────────────────────────────────────────────
|
||
|
||
private static string ResourcesDir => Path.Combine(AppContext.BaseDirectory, "Resources");
|
||
private static string ProgressFile => Path.Combine(ResourcesDir, "alarm-review-progress.json");
|
||
private static string CheckedFilePath => Path.Combine(ResourcesDir, "AlarmTranslationsChecked.de.json");
|
||
|
||
// ── German alarm display names (loaded from AlarmNames.de.json) ────────────
|
||
|
||
private static IReadOnlyDictionary<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>Pauses the scheduler (timers stopped) but keeps all progress. Resume with ResumeCampaign().</summary>
|
||
public static void StopCampaign()
|
||
{
|
||
_morningTimer?.Dispose();
|
||
_afternoonTimer?.Dispose();
|
||
_morningTimer = null;
|
||
_afternoonTimer = null;
|
||
_testBatch = null;
|
||
Console.WriteLine("[AlarmReviewService] Campaign paused — progress preserved. Call ResumeCampaign() to restart timers.");
|
||
}
|
||
|
||
/// <summary>Resumes timers without touching progress. Use after StopCampaign() or server restart.</summary>
|
||
public static void ResumeCampaign()
|
||
{
|
||
if (!File.Exists(ProgressFile))
|
||
{
|
||
Console.WriteLine("[AlarmReviewService] No progress file found — use StartCampaign() to begin.");
|
||
return;
|
||
}
|
||
LoadGermanNames();
|
||
ScheduleTimer(ref _morningTimer, 8, 0, () => RunMorningJobAsync() .GetAwaiter().GetResult());
|
||
ScheduleTimer(ref _afternoonTimer, 14, 0, () => RunAfternoonJobAsync().GetAwaiter().GetResult());
|
||
Console.WriteLine("[AlarmReviewService] Campaign resumed — timers restarted.");
|
||
}
|
||
|
||
/// <summary>Deletes all progress and stops timers. Only use to start over from scratch.</summary>
|
||
public static void ResetCampaign()
|
||
{
|
||
_morningTimer?.Dispose();
|
||
_afternoonTimer?.Dispose();
|
||
_morningTimer = null;
|
||
_afternoonTimer = null;
|
||
_testBatch = null;
|
||
|
||
if (File.Exists(ProgressFile))
|
||
File.Delete(ProgressFile);
|
||
if (File.Exists(CheckedFilePath))
|
||
File.Delete(CheckedFilePath);
|
||
|
||
Console.WriteLine("[AlarmReviewService] Campaign fully reset — all progress deleted.");
|
||
}
|
||
|
||
private static void ScheduleTimer(ref Timer? timer, int hour, int minute, Action action)
|
||
{
|
||
var now = DateTime.Now;
|
||
var next = now.Date.AddHours(hour).AddMinutes(minute);
|
||
if (now >= next) next = next.AddDays(1);
|
||
|
||
var delay = next - now;
|
||
timer = new Timer(_ => { try { action(); } catch (Exception ex) { Console.Error.WriteLine($"[AlarmReviewService] Timer error: {ex.Message}"); } },
|
||
null, delay, TimeSpan.FromDays(1));
|
||
|
||
Console.WriteLine($"[AlarmReviewService] Next {hour:D2}:{minute:D2} job scheduled at {next:yyyy-MM-dd HH:mm}");
|
||
}
|
||
|
||
// ── Morning job (8AM) ──────────────────────────────────────────────────────
|
||
|
||
private static async Task RunMorningJobAsync()
|
||
{
|
||
if (!IsWorkday(DateTime.Now))
|
||
{
|
||
Console.WriteLine("[AlarmReviewService] 8AM job skipped — weekend or public holiday.");
|
||
return;
|
||
}
|
||
Console.WriteLine("[AlarmReviewService] Running 8AM morning job...");
|
||
var progress = LoadProgress();
|
||
if (progress == null) return;
|
||
|
||
var current = progress.Batches.LastOrDefault();
|
||
if (current == null) return;
|
||
|
||
if (current.Synthesized)
|
||
{
|
||
// Next batch is sent immediately after synthesis — only act here as a safety net
|
||
// in case the server restarted before SendNextBatchAsync could run.
|
||
var nextAlreadySent = progress.Batches.Count > current.BatchNumber;
|
||
if (!nextAlreadySent)
|
||
{
|
||
Console.WriteLine($"[AlarmReviewService] Batch {current.BatchNumber} was synthesized but next batch not sent — sending now (recovery).");
|
||
await SendNextBatchAsync(progress);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
var submissionCount = current.Submissions.Values.Count(s => s != null);
|
||
|
||
// If the batch was sent today, don't treat 0 submissions as a stall —
|
||
// reviewers haven't had a full day yet. Skip and wait for tomorrow's job.
|
||
var sentToday = current.SentDate == DateTime.Now.ToString("yyyy-MM-dd");
|
||
if (submissionCount == 0 && sentToday)
|
||
{
|
||
Console.WriteLine($"[AlarmReviewService] Batch {current.BatchNumber}: sent today, 0 submissions so far — waiting for tomorrow.");
|
||
return;
|
||
}
|
||
|
||
if (submissionCount == 0)
|
||
{
|
||
const int MaxResends = 3;
|
||
if (current.ResendCount >= MaxResends)
|
||
{
|
||
// No responses after 3 resends — auto-advance using original content
|
||
Console.WriteLine($"[AlarmReviewService] Batch {current.BatchNumber}: still 0 submissions after {MaxResends} resends — auto-advancing with original content.");
|
||
foreach (var key in current.AlarmKeys)
|
||
{
|
||
var original = DiagnosticService.TryGetTranslation(key, "de") ?? AlarmKnowledgeBase.TryGetDiagnosis(key);
|
||
if (original != null) current.ImprovedEntries[key] = original;
|
||
}
|
||
current.Synthesized = true;
|
||
current.SynthesizedAt = DateTime.UtcNow.ToString("O");
|
||
current.Note = "Auto-advanced after 3 resends with no reviewer responses — original content preserved.";
|
||
SaveProgress(progress);
|
||
RegenerateCheckedFile(progress);
|
||
await SendAdminStallAlertAsync(current);
|
||
await SendNextBatchAsync(progress);
|
||
}
|
||
else
|
||
{
|
||
current.ResendCount++;
|
||
SaveProgress(progress);
|
||
Console.WriteLine($"[AlarmReviewService] Batch {current.BatchNumber}: 0 submissions — resending (attempt #{current.ResendCount}/{MaxResends}).");
|
||
await SendBatchEmailsAsync(current, isResend: true);
|
||
await SendAdminStallAlertAsync(current);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// SynthesizeBatchAsync will call SendNextBatchAsync internally when done
|
||
await SynthesizeBatchAsync(current, progress);
|
||
}
|
||
}
|
||
}
|
||
|
||
private static async Task SendNextBatchAsync(AlarmReviewProgress progress)
|
||
{
|
||
if (!IsWorkday(DateTime.Now))
|
||
{
|
||
Console.WriteLine("[AlarmReviewService] Synthesis complete but today is not a workday — next batch will be sent on the next working day.");
|
||
return; // Morning job recovery will send the next batch on the next workday
|
||
}
|
||
|
||
var nextStartIndex = progress.Batches.Count * BatchSize;
|
||
if (nextStartIndex >= AllAlarmKeys.Length)
|
||
{
|
||
Console.WriteLine("[AlarmReviewService] All 229 alarms reviewed! Sending completion email.");
|
||
await SendAdminCompletionEmailAsync(progress);
|
||
return;
|
||
}
|
||
|
||
var batch = CreateNextBatch(progress);
|
||
progress.Batches.Add(batch);
|
||
SaveProgress(progress);
|
||
|
||
await SendBatchEmailsAsync(batch, isResend: false);
|
||
|
||
var totalReviewed = progress.Batches.Count(b => b.Synthesized) * BatchSize;
|
||
Console.WriteLine($"[AlarmReviewService] Batch {batch.BatchNumber} sent. Progress: {totalReviewed}/{AllAlarmKeys.Length}.");
|
||
}
|
||
|
||
// ── Afternoon job (2PM) ────────────────────────────────────────────────────
|
||
|
||
private static async Task RunAfternoonJobAsync()
|
||
{
|
||
if (!IsWorkday(DateTime.Now))
|
||
{
|
||
Console.WriteLine("[AlarmReviewService] 2PM job skipped — weekend or public holiday.");
|
||
return;
|
||
}
|
||
Console.WriteLine("[AlarmReviewService] Running 2PM afternoon job...");
|
||
var progress = LoadProgress();
|
||
if (progress == null) return;
|
||
|
||
var current = progress.Batches.LastOrDefault();
|
||
if (current == null || current.Synthesized) return;
|
||
|
||
foreach (var (name, email) in Reviewers)
|
||
{
|
||
var key = name.ToLowerInvariant();
|
||
if (!current.Submissions.TryGetValue(key, out var sub) || sub == null)
|
||
await SendReminderEmailAsync(current, name, email);
|
||
}
|
||
}
|
||
|
||
// ── Progress persistence ───────────────────────────────────────────────────
|
||
|
||
private static readonly object _fileLock = new();
|
||
private static readonly object _submitLock = new(); // serializes the read-modify-write in SubmitFeedback
|
||
|
||
private static AlarmReviewProgress? LoadProgress()
|
||
{
|
||
if (!File.Exists(ProgressFile)) return null;
|
||
try
|
||
{
|
||
lock (_fileLock)
|
||
{
|
||
var json = File.ReadAllText(ProgressFile);
|
||
return JsonConvert.DeserializeObject<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 = DiagnosticService.TryGetTranslation(key, "de") ?? AlarmKnowledgeBase.TryGetDiagnosis(key);
|
||
var fb = feedbacks[i];
|
||
var anyChanges = !fb.ExplanationOk || !fb.CausesOk || !fb.StepsOk;
|
||
|
||
DiagnosticResponse? synthesized = null;
|
||
if (anyChanges && mistralAvailable && original != null)
|
||
synthesized = await CallMistralForSynthesisAsync(key, original, new List<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 an HTML correction form pre-filled with the current synthesized content for a specific alarm.</summary>
|
||
public static string GetCorrectionPage(int batchNumber, string alarmKey)
|
||
{
|
||
var progress = LoadProgress();
|
||
var batch = progress?.Batches.FirstOrDefault(b => b.BatchNumber == batchNumber);
|
||
if (batch == null || !batch.ImprovedEntries.TryGetValue(alarmKey, out var entry))
|
||
return "<html><body><h3>Alarm not found.</h3></body></html>";
|
||
|
||
var label = GermanName(alarmKey);
|
||
var causesJson = Newtonsoft.Json.JsonConvert.SerializeObject(entry.Causes);
|
||
var stepsJson = Newtonsoft.Json.JsonConvert.SerializeObject(entry.NextSteps);
|
||
var submitUrl = $"{BaseUrl}/ApplyAlarmCorrection";
|
||
|
||
return $$"""
|
||
<!DOCTYPE html><html lang="de">
|
||
<head><meta charset="utf-8"><title>Korrektur — {{System.Web.HttpUtility.HtmlEncode(label)}}</title>
|
||
<style>
|
||
body{font-family:Arial,sans-serif;max-width:640px;margin:32px auto;padding:0 16px;color:#333}
|
||
h2{color:#2c3e50}label{font-weight:bold;font-size:13px;display:block;margin:14px 0 4px}
|
||
textarea,input{width:100%;box-sizing:border-box;border:1px solid #ddd;border-radius:4px;padding:8px;font-size:13px;font-family:inherit}
|
||
textarea{resize:vertical}.li-row{display:flex;gap:6px;margin-bottom:5px}
|
||
.li-row textarea{flex:1;min-height:34px}.btn-rm{background:#e74c3c;color:#fff;border:none;border-radius:4px;width:26px;cursor:pointer}
|
||
.btn-add{background:#3498db;color:#fff;border:none;border-radius:4px;padding:5px 12px;cursor:pointer;font-size:12px;margin-top:4px}
|
||
.btn-save{background:#27ae60;color:#fff;border:none;border-radius:6px;padding:11px 28px;font-size:15px;cursor:pointer;margin-top:20px;font-weight:bold}
|
||
.hdr{background:#2c3e50;color:#fff;padding:16px 20px;border-radius:8px;margin-bottom:24px}
|
||
</style></head>
|
||
<body>
|
||
<div class="hdr"><div style="font-size:16px;font-weight:bold">Alarm-Korrektur</div><div style="font-size:13px;opacity:.8;margin-top:4px">{{System.Web.HttpUtility.HtmlEncode(label)}}</div></div>
|
||
<label>Erklärung</label>
|
||
<textarea id="exp" rows="3">{{System.Web.HttpUtility.HtmlEncode(entry.Explanation)}}</textarea>
|
||
<label>Mögliche Ursachen</label>
|
||
<div id="causes-ed"></div>
|
||
<label>Was zu tun ist</label>
|
||
<div id="steps-ed"></div>
|
||
<button class="btn-save" onclick="save()">Korrektur speichern</button>
|
||
<div id="msg" style="margin-top:14px;font-size:13px"></div>
|
||
<script>
|
||
var causes = {{causesJson}};
|
||
var steps = {{stepsJson}};
|
||
function renderList(type){
|
||
var arr = type==='causes'?causes:steps;
|
||
var ed = document.getElementById(type+'-ed');
|
||
ed.innerHTML = arr.map(function(v,i){
|
||
return '<div class="li-row"><textarea onchange="upd(\''+type+'\','+i+',this.value)">'+esc(v)+'</textarea>'+
|
||
'<button class="btn-rm" onclick="rm(\''+type+'\','+i+')">✕</button></div>';
|
||
}).join('')+'<button class="btn-add" onclick="add(\''+type+'\')">+ Hinzufügen</button>';
|
||
}
|
||
function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||
function upd(t,i,v){(t==='causes'?causes:steps)[i]=v;}
|
||
function rm(t,i){(t==='causes'?causes:steps).splice(i,1);renderList(t);}
|
||
function add(t){(t==='causes'?causes:steps).push('');renderList(t);}
|
||
renderList('causes'); renderList('steps');
|
||
function save(){
|
||
fetch('{{submitUrl}}',{method:'POST',headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify({batchNumber:{{batchNumber}},alarmKey:'{{alarmKey}}',
|
||
explanation:document.getElementById('exp').value,causes:causes,nextSteps:steps})})
|
||
.then(function(r){return r.json();})
|
||
.then(function(d){document.getElementById('msg').innerHTML='<span style="color:#27ae60">✓ '+d.message+'</span>';})
|
||
.catch(function(){document.getElementById('msg').innerHTML='<span style="color:#e74c3c">Fehler beim Speichern</span>';});
|
||
}
|
||
</script></body></html>
|
||
""";
|
||
}
|
||
|
||
/// <summary>Applies an admin correction to a specific alarm entry and regenerates the checked file.</summary>
|
||
public static bool ApplyCorrection(int batchNumber, string alarmKey, DiagnosticResponse correction)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(correction.Explanation) ||
|
||
correction.Causes == null || correction.Causes.Count == 0 ||
|
||
correction.NextSteps == null || correction.NextSteps.Count == 0)
|
||
return false;
|
||
|
||
lock (_fileLock)
|
||
{
|
||
var progress = LoadProgress();
|
||
var batch = progress?.Batches.FirstOrDefault(b => b.BatchNumber == batchNumber);
|
||
if (batch == null) return false;
|
||
|
||
batch.ImprovedEntries[alarmKey] = correction;
|
||
SaveProgress(progress!);
|
||
}
|
||
|
||
// Regenerate outside the lock — reads progress fresh
|
||
var prog = LoadProgress();
|
||
if (prog != null) RegenerateCheckedFile(prog);
|
||
Console.WriteLine($"[AlarmReviewService] Admin correction applied for {alarmKey} (batch {batchNumber}).");
|
||
return true;
|
||
}
|
||
|
||
/// <summary>Returns the generated AlarmTranslationsChecked.de.json content for download.</summary>
|
||
public static string? GetCheckedFileContent()
|
||
{
|
||
if (!File.Exists(CheckedFilePath)) return null;
|
||
return File.ReadAllText(CheckedFilePath);
|
||
}
|
||
|
||
// ── Synthesis ──────────────────────────────────────────────────────────────
|
||
|
||
private static async Task SynthesizeBatchAsync(BatchRecord batch, AlarmReviewProgress progress)
|
||
{
|
||
if (_synthesizing || batch.Synthesized) return;
|
||
_synthesizing = true;
|
||
|
||
try
|
||
{
|
||
Console.WriteLine($"[AlarmReviewService] Synthesizing batch {batch.BatchNumber}...");
|
||
|
||
var submissions = batch.Submissions.Values.Where(s => s != null).Select(s => s!).ToList();
|
||
if (submissions.Count == 0)
|
||
{
|
||
Console.WriteLine($"[AlarmReviewService] Batch {batch.BatchNumber}: No submissions — skipping synthesis.");
|
||
return;
|
||
}
|
||
|
||
for (int i = 0; i < batch.AlarmKeys.Count; i++)
|
||
{
|
||
var alarmKey = batch.AlarmKeys[i];
|
||
var original = DiagnosticService.TryGetTranslation(alarmKey, "de") ?? AlarmKnowledgeBase.TryGetDiagnosis(alarmKey);
|
||
if (original == null) continue;
|
||
|
||
var feedbacks = submissions
|
||
.Where(s => i < s.Feedbacks.Count)
|
||
.Select(s => s.Feedbacks[i])
|
||
.ToList();
|
||
|
||
var anyChanges = feedbacks.Any(f => !f.ExplanationOk || !f.CausesOk || !f.StepsOk);
|
||
|
||
if (!anyChanges)
|
||
{
|
||
batch.ImprovedEntries[alarmKey] = original;
|
||
continue;
|
||
}
|
||
|
||
var improved = await CallMistralForSynthesisAsync(alarmKey, original, feedbacks);
|
||
|
||
// If AI output is rejected by validation, fall back to reviewer's direct edit
|
||
// (not the original) so reviewer changes are always respected.
|
||
batch.ImprovedEntries[alarmKey] = improved ?? BuildReviewerDirectEdit(original, feedbacks);
|
||
}
|
||
|
||
batch.Synthesized = true;
|
||
batch.SynthesizedAt = DateTime.UtcNow.ToString("O");
|
||
SaveProgress(progress);
|
||
|
||
RegenerateCheckedFile(progress);
|
||
|
||
var improved2 = batch.ImprovedEntries.Count(kv =>
|
||
{
|
||
var orig = AlarmKnowledgeBase.TryGetDiagnosis(kv.Key);
|
||
return orig != null && kv.Value.Explanation != orig.Explanation;
|
||
});
|
||
Console.WriteLine($"[AlarmReviewService] Batch {batch.BatchNumber} synthesized. ~{improved2} alarms changed by AI.");
|
||
|
||
var totalReviewed = progress.Batches.Count(b => b.Synthesized) * BatchSize;
|
||
await SendAdminDailySummaryAsync(batch, Math.Min(totalReviewed, AllAlarmKeys.Length));
|
||
|
||
// Send next batch immediately after synthesis — no need to wait for 8AM
|
||
await SendNextBatchAsync(progress);
|
||
}
|
||
finally
|
||
{
|
||
_synthesizing = false;
|
||
}
|
||
}
|
||
|
||
// ── Mistral synthesis call ─────────────────────────────────────────────────
|
||
|
||
private const string MistralUrl = "https://api.mistral.ai/v1/chat/completions";
|
||
|
||
private static async Task<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-large-latest",
|
||
messages = new[] { new { role = "user", content = prompt } },
|
||
max_tokens = 600,
|
||
temperature = 0.1,
|
||
};
|
||
|
||
var responseText = await MistralUrl
|
||
.WithHeader("Authorization", $"Bearer {apiKey}")
|
||
.PostJsonAsync(requestBody)
|
||
.ReceiveString();
|
||
|
||
var envelope = JsonConvert.DeserializeObject<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();
|
||
}
|
||
|
||
var result = JsonConvert.DeserializeObject<DiagnosticResponse>(json);
|
||
return ValidateSynthesisOutput(result, feedbacks, alarmKey);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Console.Error.WriteLine($"[AlarmReviewService] Mistral failed for {alarmKey}: {ex.Message}");
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private static string BuildSynthesisPrompt(
|
||
string alarmKey, string deviceType, DiagnosticResponse original, List<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("- The original content is already in German. Output must also be in German.");
|
||
sb.AppendLine("- Use simple plain language suitable for homeowners, NOT technical jargon.");
|
||
sb.AppendLine("- For each section (Explanation / Causes / Next Steps):");
|
||
sb.AppendLine(" * If reviewer marked it 'Approved as-is': keep the original text exactly, no changes.");
|
||
sb.AppendLine(" * If reviewer provided a changed list: output EXACTLY those items — same count, same meaning.");
|
||
sb.AppendLine(" DO NOT add, remove, merge, or invent any bullets. Only improve the German phrasing.");
|
||
sb.AppendLine("- If multiple reviewers changed the same section, pick the best version or merge carefully.");
|
||
sb.AppendLine("- Explanation: 1 sentence, max 25 words.");
|
||
sb.AppendLine();
|
||
sb.AppendLine("Reply with ONLY valid JSON, no markdown fences:");
|
||
sb.AppendLine("{\"explanation\":\"...\",\"causes\":[\"...\"],\"nextSteps\":[\"...\"]}");
|
||
|
||
return sb.ToString();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Validates AI synthesis output. Returns null (reject) if output is structurally bad
|
||
/// so the caller can fall back to the reviewer's direct edit instead.
|
||
/// </summary>
|
||
private static DiagnosticResponse? ValidateSynthesisOutput(
|
||
DiagnosticResponse? result, List<ReviewFeedback> feedbacks, string alarmKey)
|
||
{
|
||
if (result == null) return null;
|
||
|
||
// Must have non-empty content
|
||
if (string.IsNullOrWhiteSpace(result.Explanation))
|
||
{
|
||
Console.Error.WriteLine($"[AlarmReviewService] Validation failed for {alarmKey}: empty explanation.");
|
||
return null;
|
||
}
|
||
if (result.Causes == null || result.Causes.Count == 0)
|
||
{
|
||
Console.Error.WriteLine($"[AlarmReviewService] Validation failed for {alarmKey}: empty causes.");
|
||
return null;
|
||
}
|
||
if (result.NextSteps == null || result.NextSteps.Count == 0)
|
||
{
|
||
Console.Error.WriteLine($"[AlarmReviewService] Validation failed for {alarmKey}: empty nextSteps.");
|
||
return null;
|
||
}
|
||
|
||
// If reviewer changed causes, output must have the exact same count
|
||
var causesFeedback = feedbacks.FirstOrDefault(f => !f.CausesOk);
|
||
if (causesFeedback != null && result.Causes.Count != causesFeedback.Causes.Count)
|
||
{
|
||
Console.Error.WriteLine($"[AlarmReviewService] Validation failed for {alarmKey}: causes count mismatch " +
|
||
$"(expected {causesFeedback.Causes.Count}, got {result.Causes.Count}).");
|
||
return null;
|
||
}
|
||
|
||
// If reviewer changed steps, output must have the exact same count
|
||
var stepsFeedback = feedbacks.FirstOrDefault(f => !f.StepsOk);
|
||
if (stepsFeedback != null && result.NextSteps.Count != stepsFeedback.NextSteps.Count)
|
||
{
|
||
Console.Error.WriteLine($"[AlarmReviewService] Validation failed for {alarmKey}: nextSteps count mismatch " +
|
||
$"(expected {stepsFeedback.NextSteps.Count}, got {result.NextSteps.Count}).");
|
||
return null;
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Builds a DiagnosticResponse from the reviewer's direct edits (majority vote per section).
|
||
/// Used when AI output is rejected by validation.
|
||
/// </summary>
|
||
private static DiagnosticResponse BuildReviewerDirectEdit(
|
||
DiagnosticResponse original, List<ReviewFeedback> feedbacks)
|
||
{
|
||
// For explanation: use first reviewer who changed it, else keep original
|
||
var expFeedback = feedbacks.FirstOrDefault(f => !f.ExplanationOk);
|
||
var explanation = expFeedback != null ? expFeedback.Explanation : original.Explanation;
|
||
|
||
// For causes: use first reviewer who changed it, else keep original
|
||
var causesFeedback = feedbacks.FirstOrDefault(f => !f.CausesOk);
|
||
var causes = causesFeedback != null ? causesFeedback.Causes : original.Causes;
|
||
|
||
// For steps: use first reviewer who changed it, else keep original
|
||
var stepsFeedback = feedbacks.FirstOrDefault(f => !f.StepsOk);
|
||
var steps = stepsFeedback != null ? stepsFeedback.NextSteps : original.NextSteps;
|
||
|
||
return new DiagnosticResponse
|
||
{
|
||
Explanation = explanation,
|
||
Causes = causes,
|
||
NextSteps = steps,
|
||
};
|
||
}
|
||
|
||
// ── AlarmTranslationsChecked.de.json generation ───────────────────────────
|
||
|
||
private static void RegenerateCheckedFile(AlarmReviewProgress progress)
|
||
{
|
||
// 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;
|
||
|
||
// Build the JSON dict: alarmKey → {Explanation, Causes, NextSteps}
|
||
// For unreviewed alarms fall back to the existing German translation
|
||
var output = new Dictionary<string, object>();
|
||
foreach (var key in AllAlarmKeys)
|
||
{
|
||
var entry = improved.TryGetValue(key, out var imp)
|
||
? imp
|
||
: DiagnosticService.TryGetTranslation(key, "de") ?? AlarmKnowledgeBase.TryGetDiagnosis(key);
|
||
if (entry == null) continue;
|
||
output[key] = new { entry.Explanation, entry.Causes, entry.NextSteps };
|
||
}
|
||
|
||
var json = JsonConvert.SerializeObject(output, Formatting.Indented);
|
||
var totalReviewed = Math.Min(progress.Batches.Count(b => b.Synthesized) * BatchSize, AllAlarmKeys.Length);
|
||
Directory.CreateDirectory(ResourcesDir);
|
||
File.WriteAllText(CheckedFilePath, json, System.Text.Encoding.UTF8);
|
||
Console.WriteLine($"[AlarmReviewService] AlarmTranslationsChecked.de.json written ({totalReviewed}/{AllAlarmKeys.Length} reviewed).");
|
||
}
|
||
|
||
private static void AppendEntry(StringBuilder sb, string key, Dictionary<string, DiagnosticResponse> improved)
|
||
{
|
||
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%% · 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||
|
||
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">☰</span>'+
|
||
'<textarea oninput="updLi(\''+type+'\','+i+',this.value)">'+esc(item)+'</textarea>'+
|
||
'<button class="btn-rm" onclick="rmLi(\''+type+'\','+i+')">✕</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 10 Minuten heute könnten hunderten von Kunden stundenlange Verwirrung ersparen. ⏱️",
|
||
"Einfache Sprache ist schwer. Danke, dass Sie sich die Zeit nehmen, es richtig zu machen. ✍️",
|
||
"Hinter jedem Alarmcode steckt ein echter Mensch, der einfach wissen möchte, ob sein Zuhause sicher ist. 🏠",
|
||
"Sie übersetzen nicht nur Text — Sie übersetzen Ingenieursprache in Menschensprache. 🌍",
|
||
"Großartige Arbeit sieht nicht immer dramatisch aus. Manchmal sieht sie so aus wie 'Passt so' zu klicken. ✅",
|
||
"Eine gut geschriebene Schritt-Liste ist tausend Support-E-Mails wert. 📋",
|
||
"Sie machen die Plattform für alle besser. Das zählt. 🌱",
|
||
"Fachliche Genauigkeit + einfache Sprache = ein zufriedener Kunde. Sie sind die Brücke. 🌉",
|
||
"Auch Roboter brauchen Menschen, die ihre Hausaufgaben prüfen. Danke, dass Sie dieser Mensch sind. 🤖",
|
||
"Ihr Fachwissen von heute wird morgen die Sicherheit eines anderen. ☀️",
|
||
"Gute Alarmmeldungen informieren nicht nur — sie beruhigen. Sie kennen den Unterschied. 🎯",
|
||
"Jede Änderung, die Sie vornehmen, ist ein kleiner Sieg der Klarheit über die Verwirrung. 🏆",
|
||
"Irgendwo arbeitet eine Solarbatterie still vor sich hin. Ihre Arbeit hilft zu erklären, wenn das nicht so ist. 🔋",
|
||
"Das Support-Team der Zukunft wird dankbar sein, dass es diesen Alarm nicht erklären muss. 😄",
|
||
"Sie können Alarme nicht verhindern — aber Sie können dafür sorgen, dass die Leute sie verstehen. 💬",
|
||
"Diese Kampagne endet in ca. 23 Tagen. Die verbesserte Wissensdatenbank wird viel länger bestehen. 📚",
|
||
};
|
||
|
||
private static string BuildReviewerEmailHtml(string name, string reviewUrl, int batchNum, int alarmCount, string quote, bool isResend)
|
||
{
|
||
var urgentBanner = isResend
|
||
? """<div style="background:#fdf3e7;border-left:4px solid #e67e22;padding:12px 16px;border-radius:0 4px 4px 0;margin-bottom:16px;font-size:13px">⚠️ <strong>Noch keine Rückmeldungen eingegangen.</strong> Dieselben Alarme werden erneut gesendet. Bitte bis 8:00 Uhr morgen früh einreichen.</div>"""
|
||
: "";
|
||
|
||
return $"""
|
||
<!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>10 Minuten</strong>.</p>
|
||
<p style="margin-bottom:18px;font-size:12px;color:#888">⏰ Bitte bis <strong>8:00 Uhr morgen früh</strong> abschließen.</p>
|
||
<div style="text-align:center;margin-bottom:22px">
|
||
<a href="{reviewUrl}" style="background:#27ae60;color:#fff;text-decoration:none;padding:11px 30px;border-radius:6px;font-size:14px;font-weight:bold;display:inline-block">Überprüfung starten →</a>
|
||
</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);
|
||
}
|
||
|
||
// Notify admin with a preview of what reviewers received
|
||
await SendAdminBatchDispatchNoticeAsync(batch, isResend);
|
||
}
|
||
|
||
private static async Task SendAdminBatchDispatchNoticeAsync(BatchRecord batch, bool isResend)
|
||
{
|
||
var totalBatches = (int)Math.Ceiling((double)AllAlarmKeys.Length / BatchSize);
|
||
var action = isResend ? "erneut gesendet" : "gesendet";
|
||
var subject = $"[Admin] Stapel {batch.BatchNumber}/{totalBatches} {action} an {Reviewers.Length} Prüfer";
|
||
|
||
var previewUrl = $"{BaseUrl}/ReviewAlarms?batch={batch.BatchNumber}&reviewer={Uri.EscapeDataString(Reviewers[0].Name)}";
|
||
var reviewerList = string.Join(", ", Reviewers.Select(r => r.Name));
|
||
|
||
var alarmRows = string.Join("\n", batch.AlarmKeys.Select((key, i) =>
|
||
$"""<tr><td style="padding:4px 12px;border-bottom:1px solid #f5f5f5;font-size:12px;color:#555">{i + 1}. {SplitCamelCase(key)} <span style="color:#aaa;font-size:11px">({(SinexcelKeySet.Contains(key) ? "Sinexcel" : "Growatt")})</span></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:650px">
|
||
<h2 style="color:#2c3e50;margin-bottom:4px">Stapel {batch.BatchNumber} {action}</h2>
|
||
<p style="color:#888;font-size:12px;margin-bottom:20px">{DateTime.Now:yyyy-MM-dd HH:mm} · {batch.AlarmKeys.Count} Alarme · Deadline: 8:00 Uhr morgen früh</p>
|
||
|
||
<p style="margin-bottom:16px">Gesendet an: <strong>{reviewerList}</strong></p>
|
||
|
||
<p style="margin-bottom:20px">
|
||
<a href="{previewUrl}" style="background:#3498db;color:#fff;text-decoration:none;padding:9px 22px;border-radius:6px;font-size:13px;display:inline-block">Formular ansehen →</a>
|
||
<span style="font-size:12px;color:#888;margin-left:10px">(alle Prüfer sehen denselben Inhalt)</span>
|
||
</p>
|
||
|
||
<h3 style="color:#2c3e50;margin-bottom:8px">Alarme in diesem Stapel</h3>
|
||
<table style="border-collapse:collapse;width:100%;margin-bottom:24px">
|
||
{alarmRows}
|
||
</table>
|
||
|
||
<p style="font-size:11px;color:#bbb">Diese E-Mail dient nur zur Information — keine Aktion erforderlich.</p>
|
||
</body></html>
|
||
""";
|
||
|
||
await SendEmailAsync(AdminEmail, subject, html);
|
||
}
|
||
|
||
private static async Task SendReminderEmailAsync(BatchRecord batch, string name, string email)
|
||
{
|
||
var reviewUrl = $"{BaseUrl}/ReviewAlarms?batch={batch.BatchNumber}&reviewer={Uri.EscapeDataString(name)}";
|
||
var subject = $"Erinnerung: Alarmprüfung Stapel {batch.BatchNumber} bis 8:00 Uhr morgen früh abschließen";
|
||
var html = $"""
|
||
<!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:00 Uhr morgen früh</strong>. Es dauert nur 10 Minuten!</p>
|
||
<p style="margin:20px 0"><a href="{reviewUrl}" style="background:#3498db;color:#fff;text-decoration:none;padding:10px 24px;border-radius:6px;font-size:13px;display:inline-block">Überprüfung abschließen →</a></p>
|
||
<p style="font-size:11px;color:#bbb">inesco Energy Monitor</p>
|
||
</body></html>
|
||
""";
|
||
await SendEmailAsync(email, subject, html);
|
||
}
|
||
|
||
private static async Task SendAdminDailySummaryAsync(BatchRecord batch, int totalReviewed)
|
||
{
|
||
var submitted = batch.Submissions.Where(kv => kv.Value != null).Select(kv => kv.Key).ToList();
|
||
var totalBatches = (int)Math.Ceiling((double)AllAlarmKeys.Length / BatchSize);
|
||
var pct = Math.Round((double)totalReviewed / AllAlarmKeys.Length * 100, 1);
|
||
var subject = $"[Alarm Review] Batch {batch.BatchNumber}/{totalBatches} synthesized — {totalReviewed}/{AllAlarmKeys.Length} alarms done ({pct}%)";
|
||
|
||
// Build before/after section for each alarm in the batch
|
||
var beforeAfterRows = new StringBuilder();
|
||
foreach (var key in batch.AlarmKeys)
|
||
{
|
||
var original = DiagnosticService.TryGetTranslation(key, "de") ?? AlarmKnowledgeBase.TryGetDiagnosis(key);
|
||
var improved = batch.ImprovedEntries.TryGetValue(key, out var imp) ? imp : null;
|
||
var label = GermanName(key);
|
||
var changed = improved != null &&
|
||
(improved.Explanation != original?.Explanation ||
|
||
!improved.Causes.SequenceEqual(original?.Causes ?? Array.Empty<string>()) ||
|
||
!improved.NextSteps.SequenceEqual(original?.NextSteps ?? Array.Empty<string>()));
|
||
|
||
var statusColor = changed ? "#e67e22" : "#27ae60";
|
||
var statusText = changed ? "KI aktualisiert" : "Unverändert";
|
||
var correctUrl = $"{BaseUrl}/CorrectAlarm?batch={batch.BatchNumber}&key={Uri.EscapeDataString(key)}";
|
||
|
||
beforeAfterRows.Append($"""
|
||
<tr><td colspan="2" style="padding:14px 0 4px;font-weight:bold;font-size:14px;color:#2c3e50;border-top:2px solid #eee">
|
||
{System.Web.HttpUtility.HtmlEncode(label)}
|
||
<span style="font-size:11px;font-weight:normal;color:{statusColor};background:#f8f9fa;padding:2px 8px;border-radius:10px">{statusText}</span>
|
||
{(changed ? $" <a href='{correctUrl}' style='font-size:11px;color:#3498db;text-decoration:none;border:1px solid #3498db;border-radius:10px;padding:2px 8px'>✏ Korrigieren</a>" : "")}
|
||
</td></tr>
|
||
""");
|
||
|
||
// Explanation
|
||
var origExp = original?.Explanation ?? "(none)";
|
||
var newExp = improved?.Explanation ?? origExp;
|
||
beforeAfterRows.Append($"""
|
||
<tr>
|
||
<td style="padding:4px 12px 4px 0;vertical-align:top;width:50%;font-size:12px;color:#888;font-style:italic">Vorher (Deutsch)</td>
|
||
<td style="padding:4px 0;vertical-align:top;width:50%;font-size:12px;color:#888;font-style:italic">Nachher (KI)</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding:2px 12px 8px 0;vertical-align:top;font-size:13px;color:#555">{System.Web.HttpUtility.HtmlEncode(origExp)}</td>
|
||
<td style="padding:2px 0 8px;vertical-align:top;font-size:13px;color:{(newExp != origExp ? "#e67e22" : "#27ae60")}">{System.Web.HttpUtility.HtmlEncode(newExp)}</td>
|
||
</tr>
|
||
""");
|
||
|
||
// 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:{(!newCauses.SequenceEqual(origCauses) ? "#e67e22" : "#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:{(!newSteps.SequenceEqual(origSteps) ? "#e67e22" : "#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>AlarmTranslationsChecked.de.json</strong> is ready on the server at <code>Resources/AlarmTranslationsChecked.de.json</code>.</p>
|
||
<h3 style="margin-top:22px;color:#2c3e50">Next Steps</h3>
|
||
<ol style="margin-top:10px;line-height:2.2;padding-left:20px">
|
||
<li>On your local machine, pull the latest <code>Resources/</code> folder from the server</li>
|
||
<li>Run <code>generate_alarm_translations.py</code> — reads <code>AlarmTranslationsChecked.de.json</code>, generates <code>en.json</code>, <code>fr.json</code>, <code>it.json</code></li>
|
||
<li>Update <code>DiagnosticService</code> to load <code>AlarmTranslations.en.json</code> for English</li>
|
||
<li>Deploy: <code>cd csharp/App/Backend && ./deploy.sh</code></li>
|
||
</ol>
|
||
<h3 style="margin-top:22px;color:#2c3e50">Campaign Summary</h3>
|
||
<table style="border-collapse:collapse;margin-top:8px">
|
||
<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");
|
||
|
||
// ── Workday check (no emails on weekends or Swiss public holidays) ──────────
|
||
|
||
private static bool IsWorkday(DateTime date)
|
||
{
|
||
if (date.DayOfWeek == DayOfWeek.Saturday || date.DayOfWeek == DayOfWeek.Sunday)
|
||
return false;
|
||
return !IsSwissPublicHoliday(date.Date);
|
||
}
|
||
|
||
private static bool IsSwissPublicHoliday(DateTime date)
|
||
{
|
||
var year = date.Year;
|
||
|
||
// Fixed federal holidays
|
||
if (date == new DateTime(year, 1, 1)) return true; // New Year's Day
|
||
if (date == new DateTime(year, 8, 1)) return true; // Swiss National Day
|
||
if (date == new DateTime(year, 12, 25)) return true; // Christmas Day
|
||
if (date == new DateTime(year, 12, 26)) return true; // St. Stephen's Day
|
||
|
||
// Easter-based holidays
|
||
var easter = GetEasterSunday(year);
|
||
if (date == easter.AddDays(-2)) return true; // Good Friday
|
||
if (date == easter) return true; // Easter Sunday
|
||
if (date == easter.AddDays(1)) return true; // Easter Monday
|
||
if (date == easter.AddDays(39)) return true; // Ascension Day
|
||
if (date == easter.AddDays(49)) return true; // Whit Sunday (Pfingstsonntag)
|
||
if (date == easter.AddDays(50)) return true; // Whit Monday (Pfingstmontag)
|
||
|
||
return false;
|
||
}
|
||
|
||
private static DateTime GetEasterSunday(int year)
|
||
{
|
||
// Anonymous Gregorian algorithm
|
||
int a = year % 19;
|
||
int b = year / 100;
|
||
int c = year % 100;
|
||
int d = b / 4;
|
||
int e = b % 4;
|
||
int f = (b + 8) / 25;
|
||
int g = (b - f + 1) / 3;
|
||
int h = (19 * a + b - d - g + 15) % 30;
|
||
int i = c / 4;
|
||
int k = c % 4;
|
||
int l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||
int m = (a + 11 * h + 22 * l) / 451;
|
||
int month = (h + l - 7 * m + 114) / 31;
|
||
int day = ((h + l - 7 * m + 114) % 31) + 1;
|
||
return new DateTime(year, month, day);
|
||
}
|
||
}
|