From 2f8eda5e7e90762e0e81396a0936841956bbe166 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Wed, 25 Feb 2026 06:48:23 +0100 Subject: [PATCH] build easy form to collect sodistore home alarm diagnosis review --- csharp/App/Backend/Backend.csproj | 3 + csharp/App/Backend/Controller.cs | 63 + csharp/App/Backend/Program.cs | 1 + .../App/Backend/Resources/AlarmNames.de.json | 458 +++--- .../Backend/Services/AlarmReviewService.cs | 1452 +++++++++++++++++ 5 files changed, 1748 insertions(+), 229 deletions(-) create mode 100644 csharp/App/Backend/Services/AlarmReviewService.cs diff --git a/csharp/App/Backend/Backend.csproj b/csharp/App/Backend/Backend.csproj index 9637fd428..137c54d42 100644 --- a/csharp/App/Backend/Backend.csproj +++ b/csharp/App/Backend/Backend.csproj @@ -47,6 +47,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index ff3fb1176..4d7ecb7e4 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -1255,6 +1255,69 @@ public class Controller : ControllerBase return Redirect($"https://monitor.inesco.energy/?username={user.Email}&reset=true"); // TODO: move to settings file } + // ── Alarm Review Campaign ──────────────────────────────────────────────── + + [HttpPost(nameof(SendTestAlarmReview))] + public async Task SendTestAlarmReview() + { + await AlarmReviewService.SendTestBatchAsync(); + return Ok(new { message = "Test review email sent to liu@inesco.energy. Check your inbox." }); + } + + [HttpPost(nameof(StartAlarmReviewCampaign))] + public ActionResult StartAlarmReviewCampaign() + { + AlarmReviewService.StartCampaign(); + return Ok(new { message = "Alarm review campaign started." }); + } + + [HttpPost(nameof(StopAlarmReviewCampaign))] + public ActionResult StopAlarmReviewCampaign() + { + AlarmReviewService.StopCampaign(); + return Ok(new { message = "Campaign stopped and progress file deleted. Safe to redeploy." }); + } + + [HttpGet(nameof(ReviewAlarms))] + public ActionResult ReviewAlarms(int batch, string reviewer) + { + var html = AlarmReviewService.GetReviewPage(batch, reviewer); + if (html is null) return NotFound("Batch not found or reviewer not recognised."); + return Content(html, "text/html"); + } + + [HttpPost(nameof(SubmitAlarmReview))] + public async Task SubmitAlarmReview(int batch, string? reviewer, [FromBody] List? feedbacks) + { + // Batch 0 = test mode — run dry-run synthesis and return preview HTML (nothing is saved) + if (batch == 0) + { + var previewHtml = await AlarmReviewService.PreviewSynthesisAsync(feedbacks); + return Ok(new { preview = previewHtml }); + } + + var ok = AlarmReviewService.SubmitFeedback(batch, reviewer, feedbacks); + return ok ? Ok(new { message = "Feedback saved. Thank you!" }) + : BadRequest("Batch not found, reviewer not recognised, or already submitted."); + } + + [HttpGet(nameof(GetAlarmReviewStatus))] + public ActionResult GetAlarmReviewStatus() + { + return Ok(AlarmReviewService.GetStatus()); + } + + [HttpGet(nameof(DownloadCheckedKnowledgeBase))] + public ActionResult DownloadCheckedKnowledgeBase() + { + var content = AlarmReviewService.GetCheckedFileContent(); + if (content is null) return NotFound("AlarmKnowledgeBaseChecked.cs has not been generated yet."); + + return File(System.Text.Encoding.UTF8.GetBytes(content), + "text/plain", + "AlarmKnowledgeBaseChecked.cs"); + } + } diff --git a/csharp/App/Backend/Program.cs b/csharp/App/Backend/Program.cs index 8bc1da94c..0ae6dc02e 100644 --- a/csharp/App/Backend/Program.cs +++ b/csharp/App/Backend/Program.cs @@ -27,6 +27,7 @@ public static class Program Db.Init(); LoadEnvFile(); DiagnosticService.Initialize(); + AlarmReviewService.StartDailyScheduler(); var builder = WebApplication.CreateBuilder(args); RabbitMqManager.InitializeEnvironment(); diff --git a/csharp/App/Backend/Resources/AlarmNames.de.json b/csharp/App/Backend/Resources/AlarmNames.de.json index 45a4144f2..a4fbbf6af 100644 --- a/csharp/App/Backend/Resources/AlarmNames.de.json +++ b/csharp/App/Backend/Resources/AlarmNames.de.json @@ -1,231 +1,231 @@ { - "alarm_AbnormalGridVoltage": "Unnormale Netzspannung", - "alarm_AbnormalGridFrequency": "Unnormale Netzfrequenz", - "alarm_InvertedSequenceOfGridVoltage": "Falsche Phasenreihenfolge", - "alarm_GridVoltagePhaseLoss": "Phasenausfall im Netz", - "alarm_AbnormalGridCurrent": "Unnormaler Netzstrom", - "alarm_AbnormalOutputVoltage": "Ungewöhnliche Ausgangsspannung", - "alarm_AbnormalOutputFrequency": "Ungewöhnliche Ausgangsfrequenz", - "alarm_AbnormalNullLine": "Fehlerhafter Nullleiter", - "alarm_AbnormalOffGridOutputVoltage": "Ungewöhnliche Backup-Spannung", - "alarm_ExcessivelyHighAmbientTemperature": "Zu hohe Umgebungstemperatur", - "alarm_ExcessiveRadiatorTemperature": "Überhitzter Kühlkörper", - "alarm_PcbOvertemperature": "Überhitzte Leiterplatte", - "alarm_DcConverterOvertemperature": "Überhitzter DC-Wandler", - "alarm_InverterOvertemperatureAlarm": "Warnung: Überhitzung", - "alarm_InverterOvertemperature": "Wechselrichter überhitzt", - "alarm_DcConverterOvertemperatureAlarm": "Übertemperaturalarm DC-Wandler", - "alarm_InsulationFault": "Isolationsfehler", - "alarm_LeakageProtectionFault": "Leckschutzfehler", - "alarm_AbnormalLeakageSelfCheck": "Anomaler Leckstrom-Selbsttest", - "alarm_PoorGrounding": "Schlechte Erdung", - "alarm_FanFault": "Lüfterfehler", - "alarm_AuxiliaryPowerFault": "Hilfsstromversorgung Fehler", - "alarm_ModelCapacityFault": "Modellkapazitätsfehler", - "alarm_AbnormalLightningArrester": "Überspannungsschutz Fehler", - "alarm_IslandProtection": "Inselbetrieb Schutz", - "alarm_Battery1NotConnected": "Batterie 1 nicht verbunden", - "alarm_Battery1Overvoltage": "Batterie 1 Überspannung", - "alarm_Battery1Undervoltage": "Batterie 1 Unterspannung", - "alarm_Battery1DischargeEnd": "Batterie 1 Entladung beendet", - "alarm_Battery1Inverted": "Batterie 1 Polarität vertauscht", - "alarm_Battery1OverloadTimeout": "Batterie 1 Überlastung", - "alarm_Battery1SoftStartFailure": "Batterie 1 Startfehler", - "alarm_Battery1PowerTubeFault": "Batterie 1 Leistungsteil defekt", - "alarm_Battery1InsufficientPower": "Batterie 1 Leistung unzureichend", - "alarm_Battery1BackupProhibited": "Batterie 1 Backup gesperrt", - "alarm_Battery2NotConnected": "Batterie 2 nicht verbunden", - "alarm_Battery2Overvoltage": "Batterie 2 Überspannung", - "alarm_Battery2Undervoltage": "Batterie 2 Unterspannung", - "alarm_Battery2DischargeEnd": "Batterie 2 Entladung beendet", - "alarm_Battery2Inverted": "Batterie 2 falsch angeschlossen", - "alarm_Battery2OverloadTimeout": "Batterie 2 Überlastung", - "alarm_Battery2SoftStartFailure": "Batterie 2 Startfehler", - "alarm_Battery2PowerTubeFault": "Batterie 2 Leistungsteil defekt", - "alarm_Battery2InsufficientPower": "Batterie 2 Leistung unzureichend", - "alarm_Battery2BackupProhibited": "Batterie 2 Backup gesperrt", - "alarm_LithiumBattery1ChargeForbidden": "Lithium-Batterie 1 Ladeverbot", - "alarm_LithiumBattery1DischargeForbidden": "Lithium-Batterie 1 Entladeverbot", - "alarm_LithiumBattery2ChargeForbidden": "Lithium-Batterie 2 Ladeverbot", - "alarm_LithiumBattery2DischargeForbidden": "Lithium-Batterie 2 Entladeverbot", - "alarm_LithiumBattery1Full": "Lithium-Batterie 1 voll", - "alarm_LithiumBattery1DischargeEnd": "Lithium-Batterie 1 entladen", - "alarm_LithiumBattery2Full": "Lithium-Batterie 2 voll", - "alarm_LithiumBattery2DischargeEnd": "Lithium-Batterie 2 entladen", - "alarm_LeadBatteryTemperatureAbnormality": "Batterietemperatur abnormal", - "alarm_BatteryAccessMethodError": "Batteriezugriffsfehler", - "alarm_Pv1NotAccessed": "PV1 nicht erreichbar", - "alarm_Pv1Overvoltage": "PV1 Überspannung", - "alarm_AbnormalPv1CurrentSharing": "Ungleichmäßiger PV1-Strom", - "alarm_Pv1PowerTubeFault": "PV1 Leistungstubus defekt", - "alarm_Pv1SoftStartFailure": "PV1 Soft-Start fehlgeschlagen", - "alarm_Pv1OverloadTimeout": "PV1-Überlastung", - "alarm_Pv1InsufficientPower": "PV1-Schwacher Strom", - "alarm_Photovoltaic1Overcurrent": "PV1-Überstrom", - "alarm_Pv2NotAccessed": "PV2-Nicht erkannt", - "alarm_Pv2Overvoltage": "PV2-Überspannung", - "alarm_AbnormalPv2CurrentSharing": "Ungewöhnliche Stromverteilung PV2", - "alarm_Pv2PowerTubeFault": "PV2-Leistungsrohrfehler", - "alarm_Pv2SoftStartFailure": "PV2-Softstart fehlgeschlagen", - "alarm_Pv2OverloadTimeout": "PV2-Überlastung Timeout", - "alarm_Pv2InsufficientPower": "Unzureichende Leistung PV2", - "alarm_Pv3NotConnected": "PV3 nicht verbunden", - "alarm_Pv3Overvoltage": "PV3 Überspannung", - "alarm_Pv3AverageCurrentAnomaly": "PV3 Stromanomalie", - "alarm_Pv3PowerTubeFailure": "PV3 Leistungselektronik defekt", - "alarm_Pv3SoftStartFailure": "PV3 Startfehler", - "alarm_Pv3OverloadTimeout": "PV3-Überlastung", - "alarm_Pv3ReverseConnection": "PV3-Falschpolung", - "alarm_Pv4NotConnected": "PV4 Nicht Verbunden", - "alarm_Pv4Overvoltage": "PV4 Überspannung", - "alarm_Pv4AverageCurrentAnomaly": "PV4 Stromanomalie", - "alarm_Pv4PowerTubeFailure": "PV4-Leistungsrohr defekt", - "alarm_Pv4SoftStartFailure": "PV4-Softstart fehlgeschlagen", - "alarm_Pv4OverloadTimeout": "PV4-Überlastung", - "alarm_Pv4ReverseConnection": "PV4 falsch angeschlossen", - "alarm_InsufficientPhotovoltaicPower": "Zu wenig Solarstrom", - "alarm_DcBusOvervoltage": "DC-Bus Überspannung", - "alarm_DcBusUndervoltage": "DC-Bus Unterspannung", - "alarm_DcBusVoltageUnbalance": "DC-Bus Spannungsungleichgewicht", - "alarm_BusSlowOvervoltage": "Langsame DC-Bus Überspannung", - "alarm_HardwareBusOvervoltage": "Hardware DC-Bus Überspannung", - "alarm_BusSoftStartFailure": "Fehler beim sanften Start", - "alarm_InverterPowerTubeFault": "Wechselrichter-Leistungshalbleiter defekt", - "alarm_HardwareOvercurrent": "Hardware-Überstrom", - "alarm_DcConverterOvervoltage": "DC-Wandler Überspannung", - "alarm_DcConverterHardwareOvervoltage": "DC-Wandler Hardware-Überspannung", - "alarm_DcConverterOvercurrent": "DC-Wandler Überstrom", - "alarm_DcConverterHardwareOvercurrent": "DC-Wandler Hardware-Überstrom", - "alarm_DcConverterResonatorOvercurrent": "DC-Wandler Resonanz-Überstrom", - "alarm_SystemOutputOverload": "Systemausgang überlastet", - "alarm_InverterOverload": "Wechselrichter überlastet", - "alarm_InverterOverloadTimeout": "Wechselrichter-Überlastung", - "alarm_LoadPowerOverload": "Überlastung der Lastleistung", - "alarm_BalancedCircuitOverloadTimeout": "Phasenausgleich-Überlastung", - "alarm_InverterSoftStartFailure": "Wechselrichter-Softstart-Fehler", - "alarm_Dsp1ParameterSettingFault": "DSP-Parameter-Fehler", - "alarm_Dsp2ParameterSettingFault": "DSP2 Parameterfehler", - "alarm_DspVersionCompatibilityFault": "DSP-Versionen nicht kompatibel", - "alarm_CpldVersionCompatibilityFault": "CPLD-Version nicht kompatibel", - "alarm_CpldCommunicationFault": "CPLD-Kommunikationsfehler", - "alarm_DspCommunicationFault": "DSP-Kommunikationsfehler", - "alarm_OutputVoltageDcOverlimit": "DC-Spannung zu hoch", - "alarm_OutputCurrentDcOverlimit": "DC-Strom zu hoch", - "alarm_RelaySelfCheckFails": "Relais-Selbsttest fehlgeschlagen", - "alarm_InverterRelayOpen": "Wechselrichter-Relais offen", - "alarm_InverterRelayShortCircuit": "Wechselrichter-Relais Kurzschluss", - "alarm_OpenCircuitOfPowerGridRelay": "Netzrelais offen", - "alarm_ShortCircuitOfPowerGridRelay": "Netzrelais kurzgeschlossen", - "alarm_GeneratorRelayOpenCircuit": "Generatorrelais offen", - "alarm_GeneratorRelayShortCircuit": "Generatorrelais kurzgeschlossen", - "alarm_AbnormalInverter": "Wechselrichter abnormal", - "alarm_ParallelCommunicationAlarm": "Parallelkommunikationsalarm", - "alarm_ParallelModuleMissing": "Parallelmodul fehlt", - "alarm_DuplicateMachineNumbersForParallelModules": "Doppelte Gerätenummern", - "alarm_ParameterConflictInParallelModule": "Parameterkonflikt im Parallelmodul", - "alarm_SystemDerating": "Systemleistung reduziert", - "alarm_PvAccessMethodErrorAlarm": "PV-Zugriffsfehler", - "alarm_ReservedAlarms4": "Reservierter Alarm 4", - "alarm_ReservedAlarms5": "Reservierter Alarm 5", - "alarm_ReverseMeterConnection": "Zähler falsch angeschlossen", - "alarm_InverterSealPulse": "Wechselrichter-Leistungsbegrenzung", - "alarm_AbnormalDieselGeneratorVoltage": "Ungewöhnliche Dieselgenerator-Spannung", - "alarm_AbnormalDieselGeneratorFrequency": "Ungewöhnliche Dieselgenerator-Frequenz", - "alarm_DieselGeneratorVoltageReverseSequence": "Falsche Phasenfolge des Generators", - "alarm_DieselGeneratorVoltageOutOfPhase": "Generator nicht synchronisiert", - "alarm_GeneratorOverload": "Generator überlastet", - "alarm_StringFault": "PV-String-Fehler", - "alarm_PvStringPidQuickConnectAbnormal": "PV-String-Anschluss defekt", - "alarm_DcSpdFunctionAbnormal": "DC-Überspannungsschutz defekt", - "alarm_PvShortCircuited": "PV-String kurzgeschlossen", - "alarm_PvBoostDriverAbnormal": "PV-Boost-Treiber defekt", - "alarm_AcSpdFunctionAbnormal": "AC-Überspannungsschutz defekt", - "alarm_DcFuseBlown": "DC-Sicherung durchgebrannt", - "alarm_DcInputVoltageTooHigh": "DC-Eingangsspannung zu hoch", - "alarm_PvReversed": "PV-Polarität vertauscht", - "alarm_PidFunctionAbnormal": "PID-Schutzfunktion gestört", - "alarm_PvStringDisconnected": "PV-String getrennt", - "alarm_PvStringCurrentUnbalanced": "PV-String Strom unausgeglichen", - "alarm_NoUtilityGrid": "Kein Stromnetz", - "alarm_GridVoltageOutOfRange": "Netzspannung außerhalb des Bereichs", - "alarm_GridFrequencyOutOfRange": "Netzfrequenz außerhalb des Bereichs", - "alarm_Overload": "Überlastung", - "alarm_MeterDisconnected": "Stromzähler getrennt", - "alarm_MeterReverselyConnected": "Zähler falsch angeschlossen", - "alarm_LinePeVoltageAbnormal": "Abnormale PE-Spannung", - "alarm_PhaseSequenceError": "Phasenfolgefehler", - "alarm_FanFailure": "Lüfterausfall", - "alarm_MeterAbnormal": "Störungsanzeige Zähler", - "alarm_OptimizerCommunicationAbnormal": "Kommunikationsstörung Optimierer", - "alarm_OverTemperature": "Überhitzung", - "alarm_OverTemperatureAlarm": "Überhitzungswarnung", - "alarm_NtcTemperatureSensorBroken": "Temperatursensor defekt", - "alarm_SyncSignalAbnormal": "Synchronisationsfehler", - "alarm_GridStartupConditionsNotMet": "Netzstartbedingungen nicht erfüllt", - "alarm_BatteryCommunicationFailure": "Batteriekommunikation fehlgeschlagen", - "alarm_BatteryDisconnected": "Batterie getrennt", - "alarm_BatteryVoltageTooHigh": "Batteriespannung zu hoch", - "alarm_BatteryVoltageTooLow": "Batteriespannung zu niedrig", - "alarm_BatteryReverseConnected": "Batterie falsch angeschlossen", - "alarm_LeadAcidTempSensorDisconnected": "Temperatursensor nicht angeschlossen", - "alarm_BatteryTemperatureOutOfRange": "Batterietemperatur außerhalb des Bereichs", - "alarm_BmsFault": "BMS-Fehler", - "alarm_LithiumBatteryOverload": "Batterie-Überlastung", - "alarm_BmsCommunicationAbnormal": "BMS-Kommunikationsfehler", - "alarm_BatterySpdAbnormal": "Batterie-Überspannungsschutz", - "alarm_OutputDcComponentBiasAbnormal": "DC-Versatz im Ausgang", - "alarm_DcComponentOverHighOutputVoltage": "DC-Komponente zu hohe Ausgangsspannung", - "alarm_OffGridOutputVoltageTooLow": "Netzunabhängige Ausgangsspannung zu niedrig", - "alarm_OffGridOutputVoltageTooHigh": "Netzunabhängige Ausgangsspannung zu hoch", - "alarm_OffGridOutputOverCurrent": "Netzunabhängiger Ausgangsüberstrom", - "alarm_OffGridOutputOverload": "Netzunabhängiger Ausgang überlastet", - "alarm_BalancedCircuitAbnormal": "Phasenausgleich gestört", - "alarm_ExportLimitationFailSafe": "Exportbegrenzung Notaus", - "alarm_DcBiasAbnormal": "DC-Vorspannung abnormal", - "alarm_HighDcComponentOutputCurrent": "Hohe DC-Komponente im Ausgangsstrom", - "alarm_BusVoltageSamplingAbnormal": "Spannungsmessung defekt", - "alarm_RelayFault": "Relaisfehler", - "alarm_BusVoltageAbnormal": "Gleichspannung abnormal", - "alarm_InternalCommunicationFailure": "Interne Kommunikation ausgefallen", - "alarm_TemperatureSensorDisconnected": "Temperatursensor getrennt", - "alarm_IgbtDriveFault": "IGBT-Ansteuerungsfehler", - "alarm_EepromError": "EEPROM-Fehler", - "alarm_AuxiliaryPowerAbnormal": "Hilfsstromversorgung abnormal", - "alarm_DcAcOvercurrentProtection": "Überstromschutz aktiviert", - "alarm_CommunicationProtocolMismatch": "Kommunikationsprotokoll-Fehler", - "alarm_DspComFirmwareMismatch": "Firmware-Inkompatibilität DSP/COM", - "alarm_DspSoftwareHardwareMismatch": "DSP-Software-Hardware-Inkompatibilität", - "alarm_CpldAbnormal": "CPLD-Fehler", - "alarm_RedundancySamplingInconsistent": "Inkonsistente redundante Messungen", - "alarm_PwmPassThroughSignalFailure": "PWM-Signalweg ausgefallen", - "alarm_AfciSelfTestFailure": "AFCI-Selbsttest fehlgeschlagen", - "alarm_PvCurrentSamplingAbnormal": "PV-Strommessung abnormal", - "alarm_AcCurrentSamplingAbnormal": "AC-Strommessung abnormal", - "alarm_BusSoftbootFailure": "DC-Bus-Vorstart fehlgeschlagen", - "alarm_EpoFault": "EPO-Fehler (Notaus)", - "alarm_MonitoringChipBootVerificationFailed": "Überwachungs-Chip Startfehler", - "alarm_BmsCommunicationFailure": "BMS-Kommunikationsfehler", - "alarm_BmsChargeDischargeFailure": "BMS-Lade-/Entladefehler", - "alarm_BatteryVoltageLow": "Batteriespannung zu niedrig", - "alarm_BatteryVoltageHigh": "Batteriespannung zu hoch", - "alarm_BatteryTemperatureAbnormal": "Batterietemperatur ungewöhnlich", - "alarm_BatteryReversed": "Batterie verkehrt herum", - "alarm_BatteryOpenCircuit": "Batteriekreis offen", - "alarm_BatteryOverloadProtection": "Batterieüberlastungsschutz", - "alarm_Bus2VoltageAbnormal": "Bus2-Spannung ungewöhnlich", - "alarm_BatteryChargeOcp": "Batterieladung Überstrom", - "alarm_BatteryDischargeOcp": "Batterieentladung Überstrom", - "alarm_BatterySoftStartFailed": "Batterie-Softstart fehlgeschlagen", - "alarm_EpsOutputShortCircuited": "EPS-Ausgang kurzgeschlossen", - "alarm_OffGridBusVoltageLow": "Netzunabhängige Busspannung zu niedrig", - "alarm_OffGridTerminalVoltageAbnormal": "Abnormale Spannung am Netzausgang", - "alarm_SoftStartFailed": "Sanfter Start fehlgeschlagen", - "alarm_OffGridOutputVoltageAbnormal": "Abnormale Ausgangsspannung im Netzmodus", - "alarm_BalancedCircuitSelfTestFailed": "Ausgleichsschaltungstest fehlgeschlagen", - "alarm_HighDcComponentOutputVoltage": "Hohe Gleichspannungskomponente im Ausgang", - "alarm_OffGridParallelSignalAbnormal": "Parallelsignalstörung", - "alarm_AFCIFault": "Lichtbogenfehler", - "alarm_GFCIHigh": "Erhöhter Fehlerstrom", - "alarm_PVVoltageHigh": "PV-Spannung zu hoch", - "alarm_OffGridBusVoltageTooLow": "Off-Grid-Busspannung zu niedrig" + "AbnormalGridVoltage": "Unnormale Netzspannung", + "AbnormalGridFrequency": "Unnormale Netzfrequenz", + "InvertedSequenceOfGridVoltage": "Falsche Phasenreihenfolge", + "GridVoltagePhaseLoss": "Phasenausfall im Netz", + "AbnormalGridCurrent": "Unnormaler Netzstrom", + "AbnormalOutputVoltage": "Ungewöhnliche Ausgangsspannung", + "AbnormalOutputFrequency": "Ungewöhnliche Ausgangsfrequenz", + "AbnormalNullLine": "Fehlerhafter Nullleiter", + "AbnormalOffGridOutputVoltage": "Ungewöhnliche Backup-Spannung", + "ExcessivelyHighAmbientTemperature": "Zu hohe Umgebungstemperatur", + "ExcessiveRadiatorTemperature": "Überhitzter Kühlkörper", + "PcbOvertemperature": "Überhitzte Leiterplatte", + "DcConverterOvertemperature": "Überhitzter DC-Wandler", + "InverterOvertemperatureAlarm": "Warnung: Überhitzung", + "InverterOvertemperature": "Wechselrichter überhitzt", + "DcConverterOvertemperatureAlarm": "Übertemperaturalarm DC-Wandler", + "InsulationFault": "Isolationsfehler", + "LeakageProtectionFault": "Leckschutzfehler", + "AbnormalLeakageSelfCheck": "Anomaler Leckstrom-Selbsttest", + "PoorGrounding": "Schlechte Erdung", + "FanFault": "Lüfterfehler", + "AuxiliaryPowerFault": "Hilfsstromversorgung Fehler", + "ModelCapacityFault": "Modellkapazitätsfehler", + "AbnormalLightningArrester": "Überspannungsschutz Fehler", + "IslandProtection": "Inselbetrieb Schutz", + "Battery1NotConnected": "Batterie 1 nicht verbunden", + "Battery1Overvoltage": "Batterie 1 Überspannung", + "Battery1Undervoltage": "Batterie 1 Unterspannung", + "Battery1DischargeEnd": "Batterie 1 Entladung beendet", + "Battery1Inverted": "Batterie 1 Polarität vertauscht", + "Battery1OverloadTimeout": "Batterie 1 Überlastung", + "Battery1SoftStartFailure": "Batterie 1 Startfehler", + "Battery1PowerTubeFault": "Batterie 1 Leistungsteil defekt", + "Battery1InsufficientPower": "Batterie 1 Leistung unzureichend", + "Battery1BackupProhibited": "Batterie 1 Backup gesperrt", + "Battery2NotConnected": "Batterie 2 nicht verbunden", + "Battery2Overvoltage": "Batterie 2 Überspannung", + "Battery2Undervoltage": "Batterie 2 Unterspannung", + "Battery2DischargeEnd": "Batterie 2 Entladung beendet", + "Battery2Inverted": "Batterie 2 falsch angeschlossen", + "Battery2OverloadTimeout": "Batterie 2 Überlastung", + "Battery2SoftStartFailure": "Batterie 2 Startfehler", + "Battery2PowerTubeFault": "Batterie 2 Leistungsteil defekt", + "Battery2InsufficientPower": "Batterie 2 Leistung unzureichend", + "Battery2BackupProhibited": "Batterie 2 Backup gesperrt", + "LithiumBattery1ChargeForbidden": "Lithium-Batterie 1 Ladeverbot", + "LithiumBattery1DischargeForbidden": "Lithium-Batterie 1 Entladeverbot", + "LithiumBattery2ChargeForbidden": "Lithium-Batterie 2 Ladeverbot", + "LithiumBattery2DischargeForbidden": "Lithium-Batterie 2 Entladeverbot", + "LithiumBattery1Full": "Lithium-Batterie 1 voll", + "LithiumBattery1DischargeEnd": "Lithium-Batterie 1 entladen", + "LithiumBattery2Full": "Lithium-Batterie 2 voll", + "LithiumBattery2DischargeEnd": "Lithium-Batterie 2 entladen", + "LeadBatteryTemperatureAbnormality": "Batterietemperatur abnormal", + "BatteryAccessMethodError": "Batteriezugriffsfehler", + "Pv1NotAccessed": "PV1 nicht erreichbar", + "Pv1Overvoltage": "PV1 Überspannung", + "AbnormalPv1CurrentSharing": "Ungleichmäßiger PV1-Strom", + "Pv1PowerTubeFault": "PV1 Leistungstubus defekt", + "Pv1SoftStartFailure": "PV1 Soft-Start fehlgeschlagen", + "Pv1OverloadTimeout": "PV1-Überlastung", + "Pv1InsufficientPower": "PV1-Schwacher Strom", + "Photovoltaic1Overcurrent": "PV1-Überstrom", + "Pv2NotAccessed": "PV2-Nicht erkannt", + "Pv2Overvoltage": "PV2-Überspannung", + "AbnormalPv2CurrentSharing": "Ungewöhnliche Stromverteilung PV2", + "Pv2PowerTubeFault": "PV2-Leistungsrohrfehler", + "Pv2SoftStartFailure": "PV2-Softstart fehlgeschlagen", + "Pv2OverloadTimeout": "PV2-Überlastung Timeout", + "Pv2InsufficientPower": "Unzureichende Leistung PV2", + "Pv3NotConnected": "PV3 nicht verbunden", + "Pv3Overvoltage": "PV3 Überspannung", + "Pv3AverageCurrentAnomaly": "PV3 Stromanomalie", + "Pv3PowerTubeFailure": "PV3 Leistungselektronik defekt", + "Pv3SoftStartFailure": "PV3 Startfehler", + "Pv3OverloadTimeout": "PV3-Überlastung", + "Pv3ReverseConnection": "PV3-Falschpolung", + "Pv4NotConnected": "PV4 Nicht Verbunden", + "Pv4Overvoltage": "PV4 Überspannung", + "Pv4AverageCurrentAnomaly": "PV4 Stromanomalie", + "Pv4PowerTubeFailure": "PV4-Leistungsrohr defekt", + "Pv4SoftStartFailure": "PV4-Softstart fehlgeschlagen", + "Pv4OverloadTimeout": "PV4-Überlastung", + "Pv4ReverseConnection": "PV4 falsch angeschlossen", + "InsufficientPhotovoltaicPower": "Zu wenig Solarstrom", + "DcBusOvervoltage": "DC-Bus Überspannung", + "DcBusUndervoltage": "DC-Bus Unterspannung", + "DcBusVoltageUnbalance": "DC-Bus Spannungsungleichgewicht", + "BusSlowOvervoltage": "Langsame DC-Bus Überspannung", + "HardwareBusOvervoltage": "Hardware DC-Bus Überspannung", + "BusSoftStartFailure": "Fehler beim sanften Start", + "InverterPowerTubeFault": "Wechselrichter-Leistungshalbleiter defekt", + "HardwareOvercurrent": "Hardware-Überstrom", + "DcConverterOvervoltage": "DC-Wandler Überspannung", + "DcConverterHardwareOvervoltage": "DC-Wandler Hardware-Überspannung", + "DcConverterOvercurrent": "DC-Wandler Überstrom", + "DcConverterHardwareOvercurrent": "DC-Wandler Hardware-Überstrom", + "DcConverterResonatorOvercurrent": "DC-Wandler Resonanz-Überstrom", + "SystemOutputOverload": "Systemausgang überlastet", + "InverterOverload": "Wechselrichter überlastet", + "InverterOverloadTimeout": "Wechselrichter-Überlastung", + "LoadPowerOverload": "Überlastung der Lastleistung", + "BalancedCircuitOverloadTimeout": "Phasenausgleich-Überlastung", + "InverterSoftStartFailure": "Wechselrichter-Softstart-Fehler", + "Dsp1ParameterSettingFault": "DSP-Parameter-Fehler", + "Dsp2ParameterSettingFault": "DSP2 Parameterfehler", + "DspVersionCompatibilityFault": "DSP-Versionen nicht kompatibel", + "CpldVersionCompatibilityFault": "CPLD-Version nicht kompatibel", + "CpldCommunicationFault": "CPLD-Kommunikationsfehler", + "DspCommunicationFault": "DSP-Kommunikationsfehler", + "OutputVoltageDcOverlimit": "DC-Spannung zu hoch", + "OutputCurrentDcOverlimit": "DC-Strom zu hoch", + "RelaySelfCheckFails": "Relais-Selbsttest fehlgeschlagen", + "InverterRelayOpen": "Wechselrichter-Relais offen", + "InverterRelayShortCircuit": "Wechselrichter-Relais Kurzschluss", + "OpenCircuitOfPowerGridRelay": "Netzrelais offen", + "ShortCircuitOfPowerGridRelay": "Netzrelais kurzgeschlossen", + "GeneratorRelayOpenCircuit": "Generatorrelais offen", + "GeneratorRelayShortCircuit": "Generatorrelais kurzgeschlossen", + "AbnormalInverter": "Wechselrichter abnormal", + "ParallelCommunicationAlarm": "Parallelkommunikationsalarm", + "ParallelModuleMissing": "Parallelmodul fehlt", + "DuplicateMachineNumbersForParallelModules": "Doppelte Gerätenummern", + "ParameterConflictInParallelModule": "Parameterkonflikt im Parallelmodul", + "SystemDerating": "Systemleistung reduziert", + "PvAccessMethodErrorAlarm": "PV-Zugriffsfehler", + "ReservedAlarms4": "Reservierter Alarm 4", + "ReservedAlarms5": "Reservierter Alarm 5", + "ReverseMeterConnection": "Zähler falsch angeschlossen", + "InverterSealPulse": "Wechselrichter-Leistungsbegrenzung", + "AbnormalDieselGeneratorVoltage": "Ungewöhnliche Dieselgenerator-Spannung", + "AbnormalDieselGeneratorFrequency": "Ungewöhnliche Dieselgenerator-Frequenz", + "DieselGeneratorVoltageReverseSequence": "Falsche Phasenfolge des Generators", + "DieselGeneratorVoltageOutOfPhase": "Generator nicht synchronisiert", + "GeneratorOverload": "Generator überlastet", + "StringFault": "PV-String-Fehler", + "PvStringPidQuickConnectAbnormal": "PV-String-Anschluss defekt", + "DcSpdFunctionAbnormal": "DC-Überspannungsschutz defekt", + "PvShortCircuited": "PV-String kurzgeschlossen", + "PvBoostDriverAbnormal": "PV-Boost-Treiber defekt", + "AcSpdFunctionAbnormal": "AC-Überspannungsschutz defekt", + "DcFuseBlown": "DC-Sicherung durchgebrannt", + "DcInputVoltageTooHigh": "DC-Eingangsspannung zu hoch", + "PvReversed": "PV-Polarität vertauscht", + "PidFunctionAbnormal": "PID-Schutzfunktion gestört", + "PvStringDisconnected": "PV-String getrennt", + "PvStringCurrentUnbalanced": "PV-String Strom unausgeglichen", + "NoUtilityGrid": "Kein Stromnetz", + "GridVoltageOutOfRange": "Netzspannung außerhalb des Bereichs", + "GridFrequencyOutOfRange": "Netzfrequenz außerhalb des Bereichs", + "Overload": "Überlastung", + "MeterDisconnected": "Stromzähler getrennt", + "MeterReverselyConnected": "Zähler falsch angeschlossen", + "LinePeVoltageAbnormal": "Abnormale PE-Spannung", + "PhaseSequenceError": "Phasenfolgefehler", + "FanFailure": "Lüfterausfall", + "MeterAbnormal": "Störungsanzeige Zähler", + "OptimizerCommunicationAbnormal": "Kommunikationsstörung Optimierer", + "OverTemperature": "Überhitzung", + "OverTemperatureAlarm": "Überhitzungswarnung", + "NtcTemperatureSensorBroken": "Temperatursensor defekt", + "SyncSignalAbnormal": "Synchronisationsfehler", + "GridStartupConditionsNotMet": "Netzstartbedingungen nicht erfüllt", + "BatteryCommunicationFailure": "Batteriekommunikation fehlgeschlagen", + "BatteryDisconnected": "Batterie getrennt", + "BatteryVoltageTooHigh": "Batteriespannung zu hoch", + "BatteryVoltageTooLow": "Batteriespannung zu niedrig", + "BatteryReverseConnected": "Batterie falsch angeschlossen", + "LeadAcidTempSensorDisconnected": "Temperatursensor nicht angeschlossen", + "BatteryTemperatureOutOfRange": "Batterietemperatur außerhalb des Bereichs", + "BmsFault": "BMS-Fehler", + "LithiumBatteryOverload": "Batterie-Überlastung", + "BmsCommunicationAbnormal": "BMS-Kommunikationsfehler", + "BatterySpdAbnormal": "Batterie-Überspannungsschutz", + "OutputDcComponentBiasAbnormal": "DC-Versatz im Ausgang", + "DcComponentOverHighOutputVoltage": "DC-Komponente zu hohe Ausgangsspannung", + "OffGridOutputVoltageTooLow": "Netzunabhängige Ausgangsspannung zu niedrig", + "OffGridOutputVoltageTooHigh": "Netzunabhängige Ausgangsspannung zu hoch", + "OffGridOutputOverCurrent": "Netzunabhängiger Ausgangsüberstrom", + "OffGridOutputOverload": "Netzunabhängiger Ausgang überlastet", + "BalancedCircuitAbnormal": "Phasenausgleich gestört", + "ExportLimitationFailSafe": "Exportbegrenzung Notaus", + "DcBiasAbnormal": "DC-Vorspannung abnormal", + "HighDcComponentOutputCurrent": "Hohe DC-Komponente im Ausgangsstrom", + "BusVoltageSamplingAbnormal": "Spannungsmessung defekt", + "RelayFault": "Relaisfehler", + "BusVoltageAbnormal": "Gleichspannung abnormal", + "InternalCommunicationFailure": "Interne Kommunikation ausgefallen", + "TemperatureSensorDisconnected": "Temperatursensor getrennt", + "IgbtDriveFault": "IGBT-Ansteuerungsfehler", + "EepromError": "EEPROM-Fehler", + "AuxiliaryPowerAbnormal": "Hilfsstromversorgung abnormal", + "DcAcOvercurrentProtection": "Überstromschutz aktiviert", + "CommunicationProtocolMismatch": "Kommunikationsprotokoll-Fehler", + "DspComFirmwareMismatch": "Firmware-Inkompatibilität DSP/COM", + "DspSoftwareHardwareMismatch": "DSP-Software-Hardware-Inkompatibilität", + "CpldAbnormal": "CPLD-Fehler", + "RedundancySamplingInconsistent": "Inkonsistente redundante Messungen", + "PwmPassThroughSignalFailure": "PWM-Signalweg ausgefallen", + "AfciSelfTestFailure": "AFCI-Selbsttest fehlgeschlagen", + "PvCurrentSamplingAbnormal": "PV-Strommessung abnormal", + "AcCurrentSamplingAbnormal": "AC-Strommessung abnormal", + "BusSoftbootFailure": "DC-Bus-Vorstart fehlgeschlagen", + "EpoFault": "EPO-Fehler (Notaus)", + "MonitoringChipBootVerificationFailed": "Überwachungs-Chip Startfehler", + "BmsCommunicationFailure": "BMS-Kommunikationsfehler", + "BmsChargeDischargeFailure": "BMS-Lade-/Entladefehler", + "BatteryVoltageLow": "Batteriespannung zu niedrig", + "BatteryVoltageHigh": "Batteriespannung zu hoch", + "BatteryTemperatureAbnormal": "Batterietemperatur ungewöhnlich", + "BatteryReversed": "Batterie verkehrt herum", + "BatteryOpenCircuit": "Batteriekreis offen", + "BatteryOverloadProtection": "Batterieüberlastungsschutz", + "Bus2VoltageAbnormal": "Bus2-Spannung ungewöhnlich", + "BatteryChargeOcp": "Batterieladung Überstrom", + "BatteryDischargeOcp": "Batterieentladung Überstrom", + "BatterySoftStartFailed": "Batterie-Softstart fehlgeschlagen", + "EpsOutputShortCircuited": "EPS-Ausgang kurzgeschlossen", + "OffGridBusVoltageLow": "Netzunabhängige Busspannung zu niedrig", + "OffGridTerminalVoltageAbnormal": "Abnormale Spannung am Netzausgang", + "SoftStartFailed": "Sanfter Start fehlgeschlagen", + "OffGridOutputVoltageAbnormal": "Abnormale Ausgangsspannung im Netzmodus", + "BalancedCircuitSelfTestFailed": "Ausgleichsschaltungstest fehlgeschlagen", + "HighDcComponentOutputVoltage": "Hohe Gleichspannungskomponente im Ausgang", + "OffGridParallelSignalAbnormal": "Parallelsignalstörung", + "AFCIFault": "Lichtbogenfehler", + "GFCIHigh": "Erhöhter Fehlerstrom", + "PVVoltageHigh": "PV-Spannung zu hoch", + "OffGridBusVoltageTooLow": "Off-Grid-Busspannung zu niedrig" } \ No newline at end of file diff --git a/csharp/App/Backend/Services/AlarmReviewService.cs b/csharp/App/Backend/Services/AlarmReviewService.cs new file mode 100644 index 000000000..3ef284dbd --- /dev/null +++ b/csharp/App/Backend/Services/AlarmReviewService.cs @@ -0,0 +1,1452 @@ +using System.Text; +using System.Text.RegularExpressions; +using Flurl.Http; +using InnovEnergy.Lib.Mailer; +using MailKit.Net.Smtp; +using MailKit.Security; +using MimeKit; +using Newtonsoft.Json; + +namespace InnovEnergy.App.Backend.Services; + +// ── Models ──────────────────────────────────────────────────────────────────── + +public class AlarmReviewProgress +{ + [JsonProperty("startedAt")] public string StartedAt { get; set; } = ""; + [JsonProperty("batches")] public List Batches { get; set; } = new(); +} + +public class BatchRecord +{ + [JsonProperty("batchNumber")] public int BatchNumber { get; set; } + [JsonProperty("sentDate")] public string SentDate { get; set; } = ""; + [JsonProperty("alarmKeys")] public List AlarmKeys { get; set; } = new(); + [JsonProperty("resendCount")] public int ResendCount { get; set; } + [JsonProperty("synthesized")] public bool Synthesized { get; set; } + [JsonProperty("synthesizedAt")] public string? SynthesizedAt { get; set; } + [JsonProperty("submissions")] public Dictionary Submissions { get; set; } = new(); + [JsonProperty("improvedEntries")] public Dictionary ImprovedEntries{ get; set; } = new(); +} + +public class ReviewerSubmission +{ + [JsonProperty("submittedAt")] public string SubmittedAt { get; set; } = ""; + [JsonProperty("feedbacks")] public List Feedbacks { get; set; } = new(); +} + +public class ReviewFeedback +{ + [JsonProperty("explanationOk")] public bool ExplanationOk { get; set; } + [JsonProperty("explanation")] public string Explanation { get; set; } = ""; + [JsonProperty("causesOk")] public bool CausesOk { get; set; } + [JsonProperty("causes")] public List Causes { get; set; } = new(); + [JsonProperty("stepsOk")] public bool StepsOk { get; set; } + [JsonProperty("nextSteps")] public List NextSteps { get; set; } = new(); + [JsonProperty("comment")] public string Comment { get; set; } = ""; +} + +// ── Service ─────────────────────────────────────────────────────────────────── + +public static class AlarmReviewService +{ + // ── Configuration ───────────────────────────────────────────────────────── + + private static readonly (string Name, string Email)[] Reviewers = + { + ("Rüdiger", "junghans@inesco.energy"), + ("Nico", "lapp@inesco.energy"), + ("Fabio", "niederberger@inesco.energy"), + ("Jan", "dustmann@inesco.energy"), + }; + + private const string AdminEmail = "liu@inesco.energy"; + private const string BaseUrl = "https://monitor.inesco.energy/api"; + private const int BatchSize = 10; + + // ── File paths ───────────────────────────────────────────────────────────── + + private static string ResourcesDir => Path.Combine(AppContext.BaseDirectory, "Resources"); + private static string ProgressFile => Path.Combine(ResourcesDir, "alarm-review-progress.json"); + private static string CheckedFilePath => Path.Combine(AppContext.BaseDirectory, "AlarmKnowledgeBaseChecked.cs"); + + // ── German alarm display names (loaded from AlarmNames.de.json) ──────────── + + private static IReadOnlyDictionary _germanNames = new Dictionary(); + + public static void LoadGermanNames() + { + var file = Path.Combine(AppContext.BaseDirectory, "Resources", "AlarmNames.de.json"); + if (!File.Exists(file)) return; + try + { + var raw = JsonConvert.DeserializeObject>(File.ReadAllText(file)); + if (raw is not null) _germanNames = raw; + Console.WriteLine($"[AlarmReviewService] Loaded {raw?.Count ?? 0} German alarm names."); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[AlarmReviewService] Failed to load AlarmNames.de.json: {ex.Message}"); + } + } + + private static string GermanName(string key) => + _germanNames.TryGetValue(key, out var name) ? name : SplitCamelCase(key); + + // ── Alarm key ordering: 135 Sinexcel + 94 Growatt = 229 total ──────────── + + private static readonly string[] SinexcelKeys = + { + "AbnormalGridVoltage", "AbnormalGridFrequency", "InvertedSequenceOfGridVoltage", + "GridVoltagePhaseLoss", "AbnormalGridCurrent", "AbnormalOutputVoltage", + "AbnormalOutputFrequency", "AbnormalNullLine", "AbnormalOffGridOutputVoltage", + "ExcessivelyHighAmbientTemperature", "ExcessiveRadiatorTemperature", "PcbOvertemperature", + "DcConverterOvertemperature", "InverterOvertemperatureAlarm", "InverterOvertemperature", + "DcConverterOvertemperatureAlarm", "InsulationFault", "LeakageProtectionFault", + "AbnormalLeakageSelfCheck", "PoorGrounding", "FanFault", "AuxiliaryPowerFault", + "ModelCapacityFault", "AbnormalLightningArrester", "IslandProtection", + "Battery1NotConnected", "Battery1Overvoltage", "Battery1Undervoltage", + "Battery1DischargeEnd", "Battery1Inverted", "Battery1OverloadTimeout", + "Battery1SoftStartFailure", "Battery1PowerTubeFault", "Battery1InsufficientPower", + "Battery1BackupProhibited", "Battery2NotConnected", "Battery2Overvoltage", + "Battery2Undervoltage", "Battery2DischargeEnd", "Battery2Inverted", + "Battery2OverloadTimeout", "Battery2SoftStartFailure", "Battery2PowerTubeFault", + "Battery2InsufficientPower", "Battery2BackupProhibited", "LithiumBattery1ChargeForbidden", + "LithiumBattery1DischargeForbidden", "LithiumBattery2ChargeForbidden", + "LithiumBattery2DischargeForbidden", "LithiumBattery1Full", "LithiumBattery1DischargeEnd", + "LithiumBattery2Full", "LithiumBattery2DischargeEnd", "LeadBatteryTemperatureAbnormality", + "BatteryAccessMethodError", "Pv1NotAccessed", "Pv1Overvoltage", + "AbnormalPv1CurrentSharing", "Pv1PowerTubeFault", "Pv1SoftStartFailure", + "Pv1OverloadTimeout", "Pv1InsufficientPower", "Photovoltaic1Overcurrent", + "Pv2NotAccessed", "Pv2Overvoltage", "AbnormalPv2CurrentSharing", "Pv2PowerTubeFault", + "Pv2SoftStartFailure", "Pv2OverloadTimeout", "Pv2InsufficientPower", + "Pv3NotConnected", "Pv3Overvoltage", "Pv3AverageCurrentAnomaly", "Pv3PowerTubeFailure", + "Pv3SoftStartFailure", "Pv3OverloadTimeout", "Pv3ReverseConnection", + "Pv4NotConnected", "Pv4Overvoltage", "Pv4AverageCurrentAnomaly", "Pv4PowerTubeFailure", + "Pv4SoftStartFailure", "Pv4OverloadTimeout", "Pv4ReverseConnection", + "InsufficientPhotovoltaicPower", "DcBusOvervoltage", "DcBusUndervoltage", + "DcBusVoltageUnbalance", "BusSlowOvervoltage", "HardwareBusOvervoltage", + "BusSoftStartFailure", "InverterPowerTubeFault", "HardwareOvercurrent", + "DcConverterOvervoltage", "DcConverterHardwareOvervoltage", "DcConverterOvercurrent", + "DcConverterHardwareOvercurrent", "DcConverterResonatorOvercurrent", + "SystemOutputOverload", "InverterOverload", "InverterOverloadTimeout", + "LoadPowerOverload", "BalancedCircuitOverloadTimeout", "InverterSoftStartFailure", + "Dsp1ParameterSettingFault", "Dsp2ParameterSettingFault", "DspVersionCompatibilityFault", + "CpldVersionCompatibilityFault", "CpldCommunicationFault", "DspCommunicationFault", + "OutputVoltageDcOverlimit", "OutputCurrentDcOverlimit", "RelaySelfCheckFails", + "InverterRelayOpen", "InverterRelayShortCircuit", "OpenCircuitOfPowerGridRelay", + "ShortCircuitOfPowerGridRelay", "GeneratorRelayOpenCircuit", "GeneratorRelayShortCircuit", + "AbnormalInverter", "ParallelCommunicationAlarm", "ParallelModuleMissing", + "DuplicateMachineNumbersForParallelModules", "ParameterConflictInParallelModule", + "SystemDerating", "PvAccessMethodErrorAlarm", "ReservedAlarms4", "ReservedAlarms5", + "ReverseMeterConnection", "InverterSealPulse", "AbnormalDieselGeneratorVoltage", + "AbnormalDieselGeneratorFrequency", "DieselGeneratorVoltageReverseSequence", + "DieselGeneratorVoltageOutOfPhase", "GeneratorOverload", + }; + + private static readonly string[] GrowattKeys = + { + "StringFault", "PvStringPidQuickConnectAbnormal", "DcSpdFunctionAbnormal", + "PvShortCircuited", "PvBoostDriverAbnormal", "AcSpdFunctionAbnormal", "DcFuseBlown", + "DcInputVoltageTooHigh", "PvReversed", "PidFunctionAbnormal", "PvStringDisconnected", + "PvStringCurrentUnbalanced", "NoUtilityGrid", "GridVoltageOutOfRange", + "GridFrequencyOutOfRange", "Overload", "MeterDisconnected", "MeterReverselyConnected", + "LinePeVoltageAbnormal", "PhaseSequenceError", "FanFailure", "MeterAbnormal", + "OptimizerCommunicationAbnormal", "OverTemperature", "OverTemperatureAlarm", + "NtcTemperatureSensorBroken", "SyncSignalAbnormal", "GridStartupConditionsNotMet", + "BatteryCommunicationFailure", "BatteryDisconnected", "BatteryVoltageTooHigh", + "BatteryVoltageTooLow", "BatteryReverseConnected", "LeadAcidTempSensorDisconnected", + "BatteryTemperatureOutOfRange", "BmsFault", "LithiumBatteryOverload", + "BmsCommunicationAbnormal", "BatterySpdAbnormal", "OutputDcComponentBiasAbnormal", + "DcComponentOverHighOutputVoltage", "OffGridOutputVoltageTooLow", + "OffGridOutputVoltageTooHigh", "OffGridOutputOverCurrent", "OffGridBusVoltageTooLow", + "OffGridOutputOverload", "BalancedCircuitAbnormal", "ExportLimitationFailSafe", + "DcBiasAbnormal", "HighDcComponentOutputCurrent", "BusVoltageSamplingAbnormal", + "RelayFault", "BusVoltageAbnormal", "InternalCommunicationFailure", + "TemperatureSensorDisconnected", "IgbtDriveFault", "EepromError", + "AuxiliaryPowerAbnormal", "DcAcOvercurrentProtection", "CommunicationProtocolMismatch", + "DspComFirmwareMismatch", "DspSoftwareHardwareMismatch", "CpldAbnormal", + "RedundancySamplingInconsistent", "PwmPassThroughSignalFailure", "AfciSelfTestFailure", + "PvCurrentSamplingAbnormal", "AcCurrentSamplingAbnormal", "BusSoftbootFailure", + "EpoFault", "MonitoringChipBootVerificationFailed", "BmsCommunicationFailure", + "BmsChargeDischargeFailure", "BatteryVoltageLow", "BatteryVoltageHigh", + "BatteryTemperatureAbnormal", "BatteryReversed", "BatteryOpenCircuit", + "BatteryOverloadProtection", "Bus2VoltageAbnormal", "BatteryChargeOcp", + "BatteryDischargeOcp", "BatterySoftStartFailed", "EpsOutputShortCircuited", + "OffGridBusVoltageLow", "OffGridTerminalVoltageAbnormal", "SoftStartFailed", + "OffGridOutputVoltageAbnormal", "BalancedCircuitSelfTestFailed", + "HighDcComponentOutputVoltage", "OffGridParallelSignalAbnormal", "AFCIFault", + "GFCIHigh", "PVVoltageHigh", + }; + + private static readonly string[] AllAlarmKeys = SinexcelKeys.Concat(GrowattKeys).ToArray(); + private static readonly HashSet SinexcelKeySet = new(SinexcelKeys); + + // ── Scheduler ───────────────────────────────────────────────────────────── + + private static Timer? _morningTimer; + private static Timer? _afternoonTimer; + private static bool _synthesizing; + + public static void StartDailyScheduler() + { + LoadGermanNames(); + ScheduleTimer(ref _morningTimer, 8, 0, () => RunMorningJobAsync() .GetAwaiter().GetResult()); + ScheduleTimer(ref _afternoonTimer, 14, 0, () => RunAfternoonJobAsync().GetAwaiter().GetResult()); + Console.WriteLine("[AlarmReviewService] Daily scheduler started (8AM + 2PM jobs)."); + } + + /// Stops the scheduler and deletes the progress file. Safe to call at any time. + public static void StopCampaign() + { + _morningTimer?.Dispose(); + _afternoonTimer?.Dispose(); + _morningTimer = null; + _afternoonTimer = null; + _testBatch = null; + + if (File.Exists(ProgressFile)) + File.Delete(ProgressFile); + + Console.WriteLine("[AlarmReviewService] Campaign stopped and progress file deleted."); + } + + private static void ScheduleTimer(ref Timer? timer, int hour, int minute, Action action) + { + var now = DateTime.Now; + var next = now.Date.AddHours(hour).AddMinutes(minute); + if (now >= next) next = next.AddDays(1); + + var delay = next - now; + timer = new Timer(_ => { try { action(); } catch (Exception ex) { Console.Error.WriteLine($"[AlarmReviewService] Timer error: {ex.Message}"); } }, + null, delay, TimeSpan.FromDays(1)); + + Console.WriteLine($"[AlarmReviewService] Next {hour:D2}:{minute:D2} job scheduled at {next:yyyy-MM-dd HH:mm}"); + } + + // ── Morning job (8AM) ────────────────────────────────────────────────────── + + private static async Task RunMorningJobAsync() + { + Console.WriteLine("[AlarmReviewService] Running 8AM morning job..."); + var progress = LoadProgress(); + if (progress == null) return; + + var current = progress.Batches.LastOrDefault(); + if (current == null) return; + + if (current.Synthesized) + { + await SendNextBatchAsync(progress); + } + else + { + var submissionCount = current.Submissions.Values.Count(s => s != null); + + if (submissionCount == 0) + { + current.ResendCount++; + SaveProgress(progress); + Console.WriteLine($"[AlarmReviewService] Batch {current.BatchNumber}: 0 submissions — resending (attempt #{current.ResendCount})."); + await SendBatchEmailsAsync(current, isResend: true); + await SendAdminStallAlertAsync(current); + } + else + { + await SynthesizeBatchAsync(current, progress); + await SendNextBatchAsync(progress); + } + } + } + + private static async Task SendNextBatchAsync(AlarmReviewProgress progress) + { + var nextStartIndex = progress.Batches.Count * BatchSize; + if (nextStartIndex >= AllAlarmKeys.Length) + { + Console.WriteLine("[AlarmReviewService] All 229 alarms reviewed! Sending completion email."); + await SendAdminCompletionEmailAsync(progress); + return; + } + + var batch = CreateNextBatch(progress); + progress.Batches.Add(batch); + SaveProgress(progress); + + await SendBatchEmailsAsync(batch, isResend: false); + + var totalReviewed = progress.Batches.Count(b => b.Synthesized) * BatchSize; + Console.WriteLine($"[AlarmReviewService] Batch {batch.BatchNumber} sent. Progress: {totalReviewed}/{AllAlarmKeys.Length}."); + } + + // ── Afternoon job (2PM) ──────────────────────────────────────────────────── + + private static async Task RunAfternoonJobAsync() + { + Console.WriteLine("[AlarmReviewService] Running 2PM afternoon job..."); + var progress = LoadProgress(); + if (progress == null) return; + + var current = progress.Batches.LastOrDefault(); + if (current == null || current.Synthesized) return; + + foreach (var (name, email) in Reviewers) + { + var key = name.ToLowerInvariant(); + if (!current.Submissions.TryGetValue(key, out var sub) || sub == null) + await SendReminderEmailAsync(current, name, email); + } + } + + // ── Progress persistence ─────────────────────────────────────────────────── + + private static readonly object _fileLock = new(); + private static readonly object _submitLock = new(); // serializes the read-modify-write in SubmitFeedback + + private static AlarmReviewProgress? LoadProgress() + { + if (!File.Exists(ProgressFile)) return null; + try + { + lock (_fileLock) + { + var json = File.ReadAllText(ProgressFile); + return JsonConvert.DeserializeObject(json); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"[AlarmReviewService] Failed to load progress: {ex.Message}"); + return null; + } + } + + private static void SaveProgress(AlarmReviewProgress p) + { + lock (_fileLock) + { + var json = JsonConvert.SerializeObject(p, Formatting.Indented); + File.WriteAllText(ProgressFile, json); + } + } + + private static BatchRecord CreateNextBatch(AlarmReviewProgress progress) + { + var startIdx = progress.Batches.Count * BatchSize; + var keys = AllAlarmKeys.Skip(startIdx).Take(BatchSize).ToList(); + + return new BatchRecord + { + BatchNumber = progress.Batches.Count + 1, + SentDate = DateTime.Now.ToString("yyyy-MM-dd"), + AlarmKeys = keys, + Submissions = Reviewers.ToDictionary(r => r.Name.ToLowerInvariant(), _ => (ReviewerSubmission?)null), + }; + } + + // ── Public API (called by Controller) ───────────────────────────────────── + + /// Starts the campaign: creates progress file and sends Batch 1. + public static void StartCampaign() + { + if (File.Exists(ProgressFile)) + { + Console.WriteLine("[AlarmReviewService] Campaign already started."); + return; + } + + var progress = new AlarmReviewProgress { StartedAt = DateTime.UtcNow.ToString("O") }; + SaveProgress(progress); + + Task.Run(async () => + { + var batch = CreateNextBatch(progress); + progress.Batches.Add(batch); + SaveProgress(progress); + await SendBatchEmailsAsync(batch, isResend: false); + Console.WriteLine("[AlarmReviewService] Campaign started! Batch 1 sent."); + }).GetAwaiter().GetResult(); + } + + /// + /// Sends a test batch of 10 alarms ONLY to the admin email so they can experience the form + /// before the real campaign starts. Uses reviewer name "Admin" and batch number 0. + /// Does NOT affect the real campaign progress file. + /// + public static async Task SendTestBatchAsync() + { + var testBatch = new BatchRecord + { + BatchNumber = 0, + SentDate = DateTime.Now.ToString("yyyy-MM-dd"), + AlarmKeys = AllAlarmKeys.Take(BatchSize).ToList(), + Submissions = new Dictionary { ["Admin"] = null }, + }; + + var testQuote = DailyQuotes[0]; + var reviewUrl = $"{BaseUrl}/ReviewAlarms?batch=0&reviewer=Admin"; + var html = BuildReviewerEmailHtml( + name: "Rüdiger", + reviewUrl: reviewUrl, + batchNum: 1, + alarmCount: BatchSize, + quote: testQuote, + isResend: false); + + await SendEmailAsync(AdminEmail, $"Alarmprüfung · Stapel 1 von {(int)Math.Ceiling((double)AllAlarmKeys.Length / BatchSize)} — Bitte heute prüfen", html); + + // Also store the test batch in memory so the review page can be served + _testBatch = testBatch; + + Console.WriteLine("[AlarmReviewService] Test batch email sent to admin."); + } + + private static BatchRecord? _testBatch; + + /// Returns the HTML review page for a given batch and reviewer. + public static string? GetReviewPage(int batchNumber, string reviewerName) + { + // Batch 0 = admin test — no auth, no campaign needed + if (batchNumber == 0) + { + var tb = _testBatch; + if (tb == null) return null; + return BuildReviewPage(tb, reviewerName); + } + + var progress = LoadProgress(); + if (progress == null) return null; + + var batch = progress.Batches.FirstOrDefault(b => b.BatchNumber == batchNumber); + if (batch == null) return null; + + if (!Reviewers.Any(r => r.Name.Equals(reviewerName, StringComparison.OrdinalIgnoreCase))) + return null; + + return BuildReviewPage(batch, reviewerName); + } + + /// Saves reviewer feedback. Triggers synthesis if all 4 reviewers have submitted. + public static bool SubmitFeedback(int batchNumber, string? reviewerName, List? feedbacks) + { + if (string.IsNullOrWhiteSpace(reviewerName)) return false; + // Batch 0 = test mode — handled separately in Controller via PreviewSynthesisAsync + if (batchNumber == 0) return true; + + BatchRecord? batchForSynthesis = null; + AlarmReviewProgress? progressForSynthesis = null; + + lock (_submitLock) // atomic read-modify-write prevents two reviewers corrupting the file + { + var progress = LoadProgress(); + if (progress == null) return false; + + var batch = progress.Batches.FirstOrDefault(b => b.BatchNumber == batchNumber); + if (batch == null || batch.Synthesized) return false; + + var reviewerKey = reviewerName.ToLowerInvariant(); + if (!Reviewers.Any(r => r.Name.ToLowerInvariant() == reviewerKey)) return false; + + if (feedbacks == null || feedbacks.Count != batch.AlarmKeys.Count) return false; + if (feedbacks.Any(f => f == null)) return false; + + foreach (var f in feedbacks) + { + f.Explanation ??= ""; + f.Comment ??= ""; + f.Causes = f.Causes?.Select(c => c ?? "").ToList() ?? new List(); + f.NextSteps = f.NextSteps?.Select(s => s ?? "").ToList() ?? new List(); + } + + // Re-submission before synthesis is fine — reviewer can correct a mistake + batch.Submissions[reviewerKey] = new ReviewerSubmission + { + SubmittedAt = DateTime.UtcNow.ToString("O"), + Feedbacks = feedbacks, + }; + SaveProgress(progress); + + var submittedCount = batch.Submissions.Values.Count(s => s != null); + Console.WriteLine($"[AlarmReviewService] Batch {batchNumber}: {reviewerName} submitted ({submittedCount}/{Reviewers.Length})."); + + if (submittedCount == Reviewers.Length) + { + Console.WriteLine($"[AlarmReviewService] Batch {batchNumber}: All {Reviewers.Length} reviewers done — synthesizing immediately."); + batchForSynthesis = batch; + progressForSynthesis = progress; + } + } + + if (batchForSynthesis != null) + _ = Task.Run(async () => await SynthesizeBatchAsync(batchForSynthesis, progressForSynthesis!)); + + return true; + } + + /// + /// Dry-run synthesis for batch 0 (test). Runs AI against submitted feedback and returns + /// HTML showing before/after for each alarm — nothing is saved to disk. + /// + public static async Task PreviewSynthesisAsync(List? feedbacks) + { + var testBatch = _testBatch; + if (testBatch == null) + return "
⚠️
Test-Batch abgelaufen
Bitte erneut einen Test-E-Mail senden und nochmal versuchen.
"; + + if (feedbacks == null || feedbacks.Count != testBatch.AlarmKeys.Count || feedbacks.Any(f => f == null)) + return "
⚠️
Ungültige Eingabe
"; + + foreach (var f in feedbacks) + { + f.Explanation ??= ""; + f.Comment ??= ""; + f.Causes = f.Causes?.Select(c => c ?? "").ToList() ?? new List(); + f.NextSteps = f.NextSteps?.Select(s => s ?? "").ToList() ?? new List(); + } + + var mistralAvailable = !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("MISTRAL_API_KEY")); + + // Run all synthesis calls in parallel + var synthTasks = testBatch.AlarmKeys.Select(async (key, i) => + { + var original = AlarmKnowledgeBase.TryGetDiagnosis(key); + var fb = feedbacks[i]; + var anyChanges = !fb.ExplanationOk || !fb.CausesOk || !fb.StepsOk; + + DiagnosticResponse? synthesized = null; + if (anyChanges && mistralAvailable && original != null) + synthesized = await CallMistralForSynthesisAsync(key, original, new List { fb }); + + return (key, original, synthesized, fb, anyChanges); + }); + var results = await Task.WhenAll(synthTasks); + + var sb = new StringBuilder(); + sb.Append("
"); + sb.Append("
"); + sb.Append("
Synthese-Vorschau (Testlauf)
"); + sb.Append("
Nichts wurde gespeichert. Hier sehen Sie, was die KI mit Ihren Änderungen synthetisieren würde:
"); + sb.Append("
"); + + if (!mistralAvailable) + sb.Append("
⚠️ Mistral API nicht konfiguriert — Es werden Ihre Änderungen angezeigt, ohne KI-Synthese.
"); + + foreach (var (key, original, synthesized, fb, anyChanges) in results) + { + if (original == null) continue; + var label = GermanName(key); + var badgeClass = SinexcelKeySet.Contains(key) ? "sin" : "gro"; + var badgeName = SinexcelKeySet.Contains(key) ? "Sinexcel" : "Growatt"; + var statusText = !anyChanges ? "Keine Änderungen" : (synthesized != null ? "KI synthetisiert" : "Ihre Änderung (kein KI)"); + var statusColor = !anyChanges ? "#27ae60" : (synthesized != null ? "#e67e22" : "#888"); + + // What will actually be used: AI result, or reviewer's direct edit if no AI + var finalResult = synthesized ?? new DiagnosticResponse + { + Explanation = fb.ExplanationOk ? original.Explanation : fb.Explanation, + Causes = fb.CausesOk ? original.Causes : fb.Causes, + NextSteps = fb.StepsOk ? original.NextSteps : fb.NextSteps, + }; + + sb.Append("
"); + sb.Append($"
{badgeName}{System.Web.HttpUtility.HtmlEncode(label)}{statusText}
"); + + if (!anyChanges) + { + sb.Append("
✓ Alle Abschnitte als korrekt markiert — keine Änderungen.
"); + } + else + { + sb.Append(""); + sb.Append(""); + + // Explanation + sb.Append($""); + sb.Append($""); + + // Causes + sb.Append(""); + + // Next Steps + sb.Append(""); + + sb.Append("
Vorher (Original)Nachher (Synthese)
{System.Web.HttpUtility.HtmlEncode(original.Explanation)}{System.Web.HttpUtility.HtmlEncode(finalResult.Explanation)}
"); + sb.Append("
URSACHEN
"); + foreach (var c in original.Causes) sb.Append($"
• {System.Web.HttpUtility.HtmlEncode(c)}
"); + sb.Append("
"); + sb.Append("
URSACHEN
"); + foreach (var c in finalResult.Causes) sb.Append($"
• {System.Web.HttpUtility.HtmlEncode(c)}
"); + sb.Append("
"); + sb.Append("
WAS ZU TUN IST
"); + foreach (var s in original.NextSteps) sb.Append($"
• {System.Web.HttpUtility.HtmlEncode(s)}
"); + sb.Append("
"); + sb.Append("
WAS ZU TUN IST
"); + foreach (var s in finalResult.NextSteps) sb.Append($"
• {System.Web.HttpUtility.HtmlEncode(s)}
"); + sb.Append("
"); + } + + sb.Append("
"); // close .card + } + + sb.Append("
"); + return sb.ToString(); + } + + /// Returns campaign status as an anonymous object (serialized to JSON by Controller). + public static object GetStatus() + { + var progress = LoadProgress(); + if (progress == null) return new { started = false }; + + var synthesized = progress.Batches.Count(b => b.Synthesized); + var totalReviewed = Math.Min(synthesized * BatchSize, AllAlarmKeys.Length); + var current = progress.Batches.LastOrDefault(); + var totalBatches = (int)Math.Ceiling((double)AllAlarmKeys.Length / BatchSize); + + return new + { + started = true, + startedAt = progress.StartedAt, + totalAlarms = AllAlarmKeys.Length, + reviewedAlarms = totalReviewed, + percentComplete = Math.Round((double)totalReviewed / AllAlarmKeys.Length * 100, 1), + completedBatches = synthesized, + totalBatches, + currentBatch = current == null ? null : (object)new + { + batchNumber = current.BatchNumber, + sentDate = current.SentDate, + synthesized = current.Synthesized, + resendCount = current.ResendCount, + submissions = current.Submissions.ToDictionary( + kv => kv.Key, + kv => kv.Value != null ? (object)new { submitted = true, at = kv.Value.SubmittedAt } : null), + }, + }; + } + + /// Returns the generated AlarmKnowledgeBaseChecked.cs content for download. + public static string? GetCheckedFileContent() + { + if (!File.Exists(CheckedFilePath)) return null; + return File.ReadAllText(CheckedFilePath); + } + + // ── Synthesis ────────────────────────────────────────────────────────────── + + private static async Task SynthesizeBatchAsync(BatchRecord batch, AlarmReviewProgress progress) + { + if (_synthesizing || batch.Synthesized) return; + _synthesizing = true; + + try + { + Console.WriteLine($"[AlarmReviewService] Synthesizing batch {batch.BatchNumber}..."); + + var submissions = batch.Submissions.Values.Where(s => s != null).Select(s => s!).ToList(); + if (submissions.Count == 0) + { + Console.WriteLine($"[AlarmReviewService] Batch {batch.BatchNumber}: No submissions — skipping synthesis."); + return; + } + + for (int i = 0; i < batch.AlarmKeys.Count; i++) + { + var alarmKey = batch.AlarmKeys[i]; + var original = AlarmKnowledgeBase.TryGetDiagnosis(alarmKey); + if (original == null) continue; + + var feedbacks = submissions + .Where(s => i < s.Feedbacks.Count) + .Select(s => s.Feedbacks[i]) + .ToList(); + + var anyChanges = feedbacks.Any(f => !f.ExplanationOk || !f.CausesOk || !f.StepsOk); + + DiagnosticResponse? improved = null; + if (anyChanges) + improved = await CallMistralForSynthesisAsync(alarmKey, original, feedbacks); + + // Fall back to original if Mistral returned nothing or no changes needed + batch.ImprovedEntries[alarmKey] = improved ?? new DiagnosticResponse + { + Explanation = original.Explanation, + Causes = original.Causes, + NextSteps = original.NextSteps, + }; + } + + batch.Synthesized = true; + batch.SynthesizedAt = DateTime.UtcNow.ToString("O"); + SaveProgress(progress); + + RegenerateCheckedFile(progress); + + var improved2 = batch.ImprovedEntries.Count(kv => + { + var orig = AlarmKnowledgeBase.TryGetDiagnosis(kv.Key); + return orig != null && kv.Value.Explanation != orig.Explanation; + }); + Console.WriteLine($"[AlarmReviewService] Batch {batch.BatchNumber} synthesized. ~{improved2} alarms changed by AI."); + + var totalReviewed = progress.Batches.Count(b => b.Synthesized) * BatchSize; + await SendAdminDailySummaryAsync(batch, Math.Min(totalReviewed, AllAlarmKeys.Length)); + } + finally + { + _synthesizing = false; + } + } + + // ── Mistral synthesis call ───────────────────────────────────────────────── + + private const string MistralUrl = "https://api.mistral.ai/v1/chat/completions"; + + private static async Task CallMistralForSynthesisAsync( + string alarmKey, DiagnosticResponse original, List feedbacks) + { + var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY"); + if (string.IsNullOrWhiteSpace(apiKey)) return null; + + var deviceType = SinexcelKeySet.Contains(alarmKey) ? "Sinexcel" : "Growatt"; + var prompt = BuildSynthesisPrompt(alarmKey, deviceType, original, feedbacks); + + try + { + var requestBody = new + { + model = "mistral-small-latest", + messages = new[] { new { role = "user", content = prompt } }, + max_tokens = 500, + temperature = 0.2, + }; + + var responseText = await MistralUrl + .WithHeader("Authorization", $"Bearer {apiKey}") + .PostJsonAsync(requestBody) + .ReceiveString(); + + var envelope = JsonConvert.DeserializeObject(responseText); + var content = (string?)envelope?.choices?[0]?.message?.content; + if (string.IsNullOrWhiteSpace(content)) return null; + + var json = content.Trim(); + if (json.StartsWith("```")) + { + var nl = json.IndexOf('\n'); + if (nl >= 0) json = json[(nl + 1)..]; + if (json.EndsWith("```")) json = json[..^3]; + json = json.Trim(); + } + + return JsonConvert.DeserializeObject(json); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[AlarmReviewService] Mistral failed for {alarmKey}: {ex.Message}"); + return null; + } + } + + private static string BuildSynthesisPrompt( + string alarmKey, string deviceType, DiagnosticResponse original, List feedbacks) + { + var sb = new StringBuilder(); + sb.AppendLine("You are a documentation specialist for residential solar+battery systems (Sinexcel/Growatt inverters)."); + sb.AppendLine("Synthesize the ORIGINAL alarm content with REVIEWER FEEDBACK to produce an IMPROVED version."); + sb.AppendLine(); + sb.AppendLine($"Alarm: {SplitCamelCase(alarmKey)} ({deviceType})"); + sb.AppendLine(); + sb.AppendLine("ORIGINAL:"); + sb.AppendLine($" Explanation: {original.Explanation}"); + sb.AppendLine(" Causes:"); + foreach (var c in original.Causes) sb.AppendLine($" - {c}"); + sb.AppendLine(" Next Steps:"); + foreach (var s in original.NextSteps) sb.AppendLine($" - {s}"); + sb.AppendLine(); + sb.AppendLine($"REVIEWER FEEDBACK ({feedbacks.Count} reviewer(s)):"); + + for (int i = 0; i < feedbacks.Count; i++) + { + var f = feedbacks[i]; + sb.AppendLine($" Reviewer {i + 1}:"); + sb.AppendLine($" Explanation: {(f.ExplanationOk ? "Approved as-is" : $"Changed to: \"{f.Explanation}\"")}"); + if (!f.CausesOk) + { + sb.AppendLine(" Causes: Changed to:"); + foreach (var c in f.Causes) sb.AppendLine($" - {c}"); + } + else sb.AppendLine(" Causes: Approved as-is"); + if (!f.StepsOk) + { + sb.AppendLine(" Next Steps: Changed to:"); + foreach (var s in f.NextSteps) sb.AppendLine($" - {s}"); + } + else sb.AppendLine(" Next Steps: Approved as-is"); + if (!string.IsNullOrWhiteSpace(f.Comment)) sb.AppendLine($" Notes: {f.Comment}"); + } + + sb.AppendLine(); + sb.AppendLine("SYNTHESIS RULES:"); + sb.AppendLine("- Use reviewer changes where provided (they have direct support experience)"); + sb.AppendLine("- If multiple reviewers changed the same section, synthesize the best version"); + sb.AppendLine("- Language target: German (Deutsch), simple plain language for homeowners, NOT technical jargon"); + sb.AppendLine("- Output MUST be in German regardless of the original content language"); + sb.AppendLine("- Explanation: exactly 1 sentence, max 25 words"); + sb.AppendLine("- Causes: 2–4 bullets, plain language"); + sb.AppendLine("- Next Steps: 2–4 action items, easiest/most accessible check first"); + sb.AppendLine(); + sb.AppendLine("Reply with ONLY valid JSON, no markdown fences:"); + sb.AppendLine("{\"explanation\":\"...\",\"causes\":[\"...\"],\"nextSteps\":[\"...\"]}"); + + return sb.ToString(); + } + + // ── AlarmKnowledgeBaseChecked.cs generation ──────────────────────────────── + + private static void RegenerateCheckedFile(AlarmReviewProgress progress) + { + // Collect all improved entries across all synthesized batches + var improved = new Dictionary(); + foreach (var batch in progress.Batches.Where(b => b.Synthesized)) + foreach (var kv in batch.ImprovedEntries) + improved[kv.Key] = kv.Value; + + var totalReviewed = progress.Batches.Count(b => b.Synthesized) * BatchSize; + var lastUpdated = DateTime.Now.ToString("yyyy-MM-dd HH:mm"); + + var sb = new StringBuilder(); + sb.AppendLine("// AUTO-GENERATED by AlarmReviewService — DO NOT EDIT MANUALLY"); + sb.AppendLine($"// Progress: {Math.Min(totalReviewed, AllAlarmKeys.Length)} / {AllAlarmKeys.Length} reviewed | Last updated: {lastUpdated}"); + sb.AppendLine(); + sb.AppendLine("namespace InnovEnergy.App.Backend.Services;"); + sb.AppendLine(); + sb.AppendLine("public static class AlarmKnowledgeBaseChecked"); + sb.AppendLine("{"); + + sb.AppendLine(" public static readonly IReadOnlyDictionary SinexcelAlarms ="); + sb.AppendLine(" new Dictionary"); + sb.AppendLine(" {"); + foreach (var key in SinexcelKeys) + AppendEntry(sb, key, improved); + sb.AppendLine(" };"); + sb.AppendLine(); + + sb.AppendLine(" public static readonly IReadOnlyDictionary GrowattAlarms ="); + sb.AppendLine(" new Dictionary"); + sb.AppendLine(" {"); + foreach (var key in GrowattKeys) + AppendEntry(sb, key, improved); + sb.AppendLine(" };"); + sb.AppendLine("}"); + + File.WriteAllText(CheckedFilePath, sb.ToString()); + Console.WriteLine($"[AlarmReviewService] AlarmKnowledgeBaseChecked.cs written ({Math.Min(totalReviewed, AllAlarmKeys.Length)}/{AllAlarmKeys.Length} reviewed)."); + } + + private static void AppendEntry(StringBuilder sb, string key, Dictionary improved) + { + var isReviewed = improved.ContainsKey(key); + var entry = isReviewed ? improved[key] : AlarmKnowledgeBase.TryGetDiagnosis(key); + if (entry == null) + { + Console.Error.WriteLine($"[AlarmReviewService] Warning: no entry found for key '{key}' — skipping."); + return; + } + + sb.AppendLine($" [\"{key}\"] = new()"); + sb.AppendLine(" {"); + sb.AppendLine($" Explanation = \"{EscapeForCSharp(entry.Explanation)}\","); + sb.AppendLine(" Causes = new[]"); + sb.AppendLine(" {"); + foreach (var c in entry.Causes) sb.AppendLine($" \"{EscapeForCSharp(c)}\","); + sb.AppendLine(" },"); + sb.AppendLine(" NextSteps = new[]"); + sb.AppendLine(" {"); + foreach (var s in entry.NextSteps) sb.AppendLine($" \"{EscapeForCSharp(s)}\","); + sb.AppendLine(" }"); + sb.AppendLine(" },"); + } + + // ── HTML review page ─────────────────────────────────────────────────────── + + private static string BuildReviewPage(BatchRecord batch, string reviewerName) + { + var alarmData = batch.AlarmKeys.Select(key => + { + var diag = AlarmKnowledgeBase.TryGetDiagnosis(key); + return new + { + key, + deviceType = SinexcelKeySet.Contains(key) ? "Sinexcel" : "Growatt", + displayName = GermanName(key), + explanation = DiagnosticService.TryGetTranslation(key, "de")?.Explanation ?? diag?.Explanation ?? "", + causes = (DiagnosticService.TryGetTranslation(key, "de")?.Causes ?? diag?.Causes)?.ToList() ?? new List(), + nextSteps = (DiagnosticService.TryGetTranslation(key, "de")?.NextSteps ?? diag?.NextSteps)?.ToList() ?? new List(), + }; + }); + + var alarmsJson = JsonConvert.SerializeObject(alarmData); + var submitUrl = $"{BaseUrl}/SubmitAlarmReview?batch={batch.BatchNumber}&reviewer={Uri.EscapeDataString(reviewerName)}"; + // Include a content hash in the key so stale localStorage is auto-invalidated when translations change + var contentHash = (Math.Abs(alarmsJson.GetHashCode()) % 100000).ToString(); + var lsKey = $"ar-b{batch.BatchNumber}-{reviewerName.ToLowerInvariant()}-{contentHash}"; + var total = batch.AlarmKeys.Count; + var quote = DailyQuotes[(Math.Max(0, batch.BatchNumber - 1)) % DailyQuotes.Length]; + + // Use string.Replace placeholders to avoid C# vs JS brace conflicts + var html = HtmlTemplate + .Replace("%%REVIEWER%%", reviewerName) + .Replace("%%BATCH%%", batch.BatchNumber.ToString()) + .Replace("%%TOTAL%%", total.ToString()) + .Replace("%%ALARMS_JSON%%", alarmsJson) + .Replace("%%SUBMIT_URL%%", submitUrl) + .Replace("%%LS_KEY%%", lsKey) + .Replace("%%QUOTE%%", quote); + + return html; + } + + // The HTML template uses %%PLACEHOLDER%% for C# injection, avoids escaping conflicts + private const string HtmlTemplate = """ + + + + + +Alarmprüfung · Stapel %%BATCH%% + + + +
+
Alarmwissensdatenbank – Überprüfung
+
Hallo %%REVIEWER%%  ·  Stapel %%BATCH%%
+
+
+
%%QUOTE%%
+
+ +
Vielen Dank für Ihre Zeit — Ihr Beitrag macht einen echten Unterschied für unsere Kunden. 🙏
+
inesco Energy Monitor
+ + + +"""; + + // ── Email builders ───────────────────────────────────────────────────────── + + private static readonly string[] DailyQuotes = + { + "Jeder Alarm, den Sie verbessern, hilft einem echten Hausbesitzer, ruhiger zu schlafen. ⚡", + "Sie sind die letzte Verteidigungslinie zwischen verwirrendem Fachjargon und einem besorgten Kunden. 🛡️", + "Eine klare Erklärung heute erspart morgen einen nächtlichen Support-Anruf. 🌙", + "Gute Dokumentation ist ein Akt der Freundlichkeit gegenüber jemandem, den man nie treffen wird. 🤝", + "Irgendwo da draußen liest ein Kunde Ihre Worte um 2 Uhr nachts. Machen Sie sie beruhigend. 🌟", + "Klarheit ist eine Superkraft. Heute setzen Sie sie ein. 💪", + "Die beste Alarmmeldung ist eine, bei der der Kunde sagt: 'Ach so, das macht Sinn'. 💡", + "Ihre 15 Minuten heute könnten hunderten von Kunden stundenlange Verwirrung ersparen. ⏱️", + "Einfache Sprache ist schwer. Danke, dass Sie sich die Zeit nehmen, es richtig zu machen. ✍️", + "Hinter jedem Alarmcode steckt ein echter Mensch, der einfach wissen möchte, ob sein Zuhause sicher ist. 🏠", + "Sie übersetzen nicht nur Text — Sie übersetzen Ingenieursprache in Menschensprache. 🌍", + "Großartige Arbeit sieht nicht immer dramatisch aus. Manchmal sieht sie so aus wie 'Passt so' zu klicken. ✅", + "Eine gut geschriebene Schritt-Liste ist tausend Support-E-Mails wert. 📋", + "Sie machen die Plattform für alle besser. Das zählt. 🌱", + "Fachliche Genauigkeit + einfache Sprache = ein zufriedener Kunde. Sie sind die Brücke. 🌉", + "Auch Roboter brauchen Menschen, die ihre Hausaufgaben prüfen. Danke, dass Sie dieser Mensch sind. 🤖", + "Ihr Fachwissen von heute wird morgen die Sicherheit eines anderen. ☀️", + "Gute Alarmmeldungen informieren nicht nur — sie beruhigen. Sie kennen den Unterschied. 🎯", + "Jede Änderung, die Sie vornehmen, ist ein kleiner Sieg der Klarheit über die Verwirrung. 🏆", + "Irgendwo arbeitet eine Solarbatterie still vor sich hin. Ihre Arbeit hilft zu erklären, wenn das nicht so ist. 🔋", + "Das Support-Team der Zukunft wird dankbar sein, dass es diesen Alarm nicht erklären muss. 😄", + "Sie können Alarme nicht verhindern — aber Sie können dafür sorgen, dass die Leute sie verstehen. 💬", + "Diese Kampagne endet in ca. 23 Tagen. Die verbesserte Wissensdatenbank wird viel länger bestehen. 📚", + }; + + private static string BuildReviewerEmailHtml(string name, string reviewUrl, int batchNum, int alarmCount, string quote, bool isResend) + { + var urgentBanner = isResend + ? """
⚠️ Noch keine Rückmeldungen eingegangen. Dieselben Alarme werden erneut gesendet. Bitte bis 8 Uhr morgen früh einreichen.
""" + : ""; + + return $""" + + + +
+ + + + +
+
Alarmwissensdatenbank – Überprüfung
+
Stapel {batchNum} · {alarmCount} Alarme
+
+ {urgentBanner} +

Hallo {name},

+

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

+

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

+ +
+ {quote} +
+

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

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

Hallo {name},

+

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

+

Überprüfung abschließen →

+

inesco Energy Monitor

+ + """; + await SendEmailAsync(email, subject, html); + } + + private static async Task SendAdminDailySummaryAsync(BatchRecord batch, int totalReviewed) + { + var submitted = batch.Submissions.Where(kv => kv.Value != null).Select(kv => kv.Key).ToList(); + var totalBatches = (int)Math.Ceiling((double)AllAlarmKeys.Length / BatchSize); + var pct = Math.Round((double)totalReviewed / AllAlarmKeys.Length * 100, 1); + var subject = $"[Alarm Review] Batch {batch.BatchNumber}/{totalBatches} synthesized — {totalReviewed}/{AllAlarmKeys.Length} alarms done ({pct}%)"; + + // Build before/after section for each alarm in the batch + var beforeAfterRows = new StringBuilder(); + foreach (var key in batch.AlarmKeys) + { + var original = AlarmKnowledgeBase.TryGetDiagnosis(key); + var improved = batch.ImprovedEntries.TryGetValue(key, out var imp) ? imp : null; + var label = GermanName(key); + var changed = improved != null && + (improved.Explanation != original?.Explanation || + !improved.Causes.SequenceEqual(original?.Causes ?? Array.Empty()) || + !improved.NextSteps.SequenceEqual(original?.NextSteps ?? Array.Empty())); + + var statusColor = changed ? "#e67e22" : "#27ae60"; + var statusText = changed ? "Aktualisiert" : "Unverändert"; + + beforeAfterRows.Append($""" + + {label}  {statusText} + + """); + + // Explanation + var origExp = original?.Explanation ?? "(none)"; + var newExp = improved?.Explanation ?? origExp; + var expStyle = newExp != origExp ? "color:#c0392b;text-decoration:line-through" : "color:#555"; + beforeAfterRows.Append($""" + + Vorher + Nachher + + + {System.Web.HttpUtility.HtmlEncode(origExp)} + {System.Web.HttpUtility.HtmlEncode(newExp)} + + """); + + // Causes + var origCauses = original?.Causes ?? Array.Empty(); + var newCauses = improved?.Causes ?? origCauses; + beforeAfterRows.Append($""" + + +
Ursachen
+ {string.Join("", origCauses.Select(c => $"
• {System.Web.HttpUtility.HtmlEncode(c)}
"))} + + +
Ursachen
+ {string.Join("", newCauses.Select(c => $"
• {System.Web.HttpUtility.HtmlEncode(c)}
"))} + + + """); + + // Steps + var origSteps = original?.NextSteps ?? Array.Empty(); + var newSteps = improved?.NextSteps ?? origSteps; + beforeAfterRows.Append($""" + + +
Was zu tun ist
+ {string.Join("", origSteps.Select(s => $"
• {System.Web.HttpUtility.HtmlEncode(s)}
"))} + + +
Was zu tun ist
+ {string.Join("", newSteps.Select(s => $"
• {System.Web.HttpUtility.HtmlEncode(s)}
"))} + + + """); + } + + var html = $""" + + +

Batch {batch.BatchNumber} Synthesized

+

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

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

Vorher → Nachher

+

Rot = Original · Grün = synthetisiertes Ergebnis

+ + {beforeAfterRows} +
+

inesco Energy Monitor

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

Alarm Review — Batch {batch.BatchNumber} Stalled

+

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

+

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

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

✅ Alarm Review Campaign Complete

+

All 229 alarms have been reviewed and synthesized.

+

AlarmKnowledgeBaseChecked.cs is ready for cutover on the server.

+

Cutover Steps

+
    +
  1. Download the checked file:
    curl "{BaseUrl}/DownloadCheckedKnowledgeBase?authToken=YOUR_TOKEN" -o AlarmKnowledgeBaseChecked.cs
  2. +
  3. Move it to csharp/App/Backend/Services/
  4. +
  5. Delete AlarmKnowledgeBase.cs
  6. +
  7. Rename class AlarmKnowledgeBaseCheckedAlarmKnowledgeBase
  8. +
  9. Run: dotnet build && ./deploy.sh
  10. +
+

Campaign Summary

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