using System.Collections.Concurrent; using Flurl.Http; using InnovEnergy.App.Backend.DataTypes; using Newtonsoft.Json; namespace InnovEnergy.App.Backend.Services; public static class WeeklyReportService { private static readonly string TmpReportDir = Environment.CurrentDirectory + "/tmp_report/"; private static readonly ConcurrentDictionary InsightCache = new(); // Bump this version when the AI prompt changes to automatically invalidate old cache files private const string CacheVersion = "v2"; /// /// Generates a full weekly report for the given installation. /// Caches the full report as JSON next to the xlsx. Cache is invalidated when xlsx is updated or CacheVersion changes. /// public static async Task GenerateReportAsync(long installationId, string installationName, string language = "en") { var xlsxPath = TmpReportDir + installationId + ".xlsx"; var cachePath = TmpReportDir + $"{installationId}_{language}_{CacheVersion}.cache.json"; // Use cached report if xlsx hasn't changed since cache was written if (File.Exists(cachePath) && File.Exists(xlsxPath)) { var xlsxModified = File.GetLastWriteTimeUtc(xlsxPath); var cacheModified = File.GetLastWriteTimeUtc(cachePath); if (cacheModified > xlsxModified) { try { var cached = JsonConvert.DeserializeObject( await File.ReadAllTextAsync(cachePath)); if (cached != null) { Console.WriteLine($"[WeeklyReportService] Returning cached report for installation {installationId} ({language})."); return cached; } } catch { // Cache corrupt — regenerate } } } var allDays = ExcelDataParser.Parse(xlsxPath); var report = await GenerateReportFromDataAsync(allDays, installationName, language); // Write cache try { await File.WriteAllTextAsync(cachePath, JsonConvert.SerializeObject(report)); } catch (Exception ex) { Console.Error.WriteLine($"[WeeklyReportService] Could not write cache: {ex.Message}"); } return report; } /// /// Core report generation from daily data. Data-source agnostic. /// public static async Task GenerateReportFromDataAsync( List allDays, string installationName, string language = "en") { // Sort by date allDays = allDays.OrderBy(d => d.Date).ToList(); // Split into previous week and current week List previousWeekDays; List currentWeekDays; if (allDays.Count > 7) { previousWeekDays = allDays.Take(allDays.Count - 7).ToList(); currentWeekDays = allDays.Skip(allDays.Count - 7).ToList(); } else { previousWeekDays = new List(); currentWeekDays = allDays; } var currentSummary = Summarize(currentWeekDays); var previousSummary = previousWeekDays.Count > 0 ? Summarize(previousWeekDays) : null; // Calculate key ratios for current week var selfSufficiency = currentSummary.TotalConsumption > 0 ? Math.Round((currentSummary.TotalConsumption - currentSummary.TotalGridImport) / currentSummary.TotalConsumption * 100, 1) : 0; var selfConsumption = currentSummary.TotalPvProduction > 0 ? Math.Round((currentSummary.TotalPvProduction - currentSummary.TotalGridExport) / currentSummary.TotalPvProduction * 100, 1) : 0; var batteryEfficiency = currentSummary.TotalBatteryCharged > 0 ? Math.Round(currentSummary.TotalBatteryDischarged / currentSummary.TotalBatteryCharged * 100, 1) : 0; var gridDependency = currentSummary.TotalConsumption > 0 ? Math.Round(currentSummary.TotalGridImport / currentSummary.TotalConsumption * 100, 1) : 0; // Week-over-week changes var pvChange = PercentChange(previousSummary?.TotalPvProduction, currentSummary.TotalPvProduction); var consumptionChange = PercentChange(previousSummary?.TotalConsumption, currentSummary.TotalConsumption); var gridImportChange = PercentChange(previousSummary?.TotalGridImport, currentSummary.TotalGridImport); // AI insight var aiInsight = await GetAiInsightAsync(currentWeekDays, currentSummary, previousSummary, selfSufficiency, gridDependency, batteryEfficiency, installationName, language); return new WeeklyReportResponse { InstallationName = installationName, PeriodStart = currentWeekDays.First().Date, PeriodEnd = currentWeekDays.Last().Date, CurrentWeek = currentSummary, PreviousWeek = previousSummary, SelfSufficiencyPercent = selfSufficiency, SelfConsumptionPercent = selfConsumption, BatteryEfficiencyPercent = batteryEfficiency, GridDependencyPercent = gridDependency, PvChangePercent = pvChange, ConsumptionChangePercent = consumptionChange, GridImportChangePercent = gridImportChange, DailyData = allDays, AiInsight = aiInsight, }; } private static WeeklySummary Summarize(List days) => new() { TotalPvProduction = Math.Round(days.Sum(d => d.PvProduction), 1), TotalConsumption = Math.Round(days.Sum(d => d.LoadConsumption), 1), TotalGridImport = Math.Round(days.Sum(d => d.GridImport), 1), TotalGridExport = Math.Round(days.Sum(d => d.GridExport), 1), TotalBatteryCharged = Math.Round(days.Sum(d => d.BatteryCharged), 1), TotalBatteryDischarged = Math.Round(days.Sum(d => d.BatteryDischarged), 1), }; private static double PercentChange(double? previous, double current) { if (previous is null or 0) return 0; return Math.Round((current - previous.Value) / previous.Value * 100, 1); } // ── Mistral AI Insight ────────────────────────────────────────────── private static readonly string MistralUrl = "https://api.mistral.ai/v1/chat/completions"; private static string LanguageName(string code) => code switch { "de" => "German", "fr" => "French", "it" => "Italian", _ => "English" }; private static async Task GetAiInsightAsync( List currentWeek, WeeklySummary current, WeeklySummary? previous, double selfSufficiency, double gridDependency, double batteryEfficiency, string installationName, string language = "en") { var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY"); if (string.IsNullOrWhiteSpace(apiKey)) { Console.WriteLine("[WeeklyReportService] MISTRAL_API_KEY not set — skipping AI insight."); return "AI insight unavailable (API key not configured)."; } // Cache key: installation + period + language var cacheKey = $"{installationName}_{currentWeek.Last().Date}_{language}"; if (InsightCache.TryGetValue(cacheKey, out var cached)) return cached; // Build compact prompt var dayLines = string.Join("\n", currentWeek.Select(d => { var dayName = DateTime.Parse(d.Date).ToString("ddd"); return $"{dayName} {d.Date}: PV={d.PvProduction:F1} Load={d.LoadConsumption:F1} GridIn={d.GridImport:F1} GridOut={d.GridExport:F1} BattIn={d.BatteryCharged:F1} BattOut={d.BatteryDischarged:F1}"; })); var comparison = previous != null ? $"vs Last week: PV {current.TotalPvProduction} vs {previous.TotalPvProduction}, Grid Import {current.TotalGridImport} vs {previous.TotalGridImport}, Consumption {current.TotalConsumption} vs {previous.TotalConsumption}" : "No previous week data available."; var solarSavings = Math.Round(current.TotalPvProduction - current.TotalGridExport, 1); var prompt = $@"You are an energy advisor for a SodistoreHome installation: ""{installationName}"". Write exactly 4 bullet points (each on its own line starting with ""- ""). No bold markers, no asterisks, no markdown — plain text only. IMPORTANT FORMAT RULE: Each bullet MUST start with a short title followed by a colon, then the description. Example: ""- Title label: Description text here."" Translate the title label into {LanguageName(language)} but always keep the ""Title: description"" structure. 1. Solar savings: this week the system saved {solarSavings} kWh from the grid. Explain what this means in simple terms (e.g. equivalent to X days of average household use, or roughly X CHF saved at ~0.27 CHF/kWh). 2. Best vs worst solar day: name the best and worst days with their PV kWh values. Mention likely weather reason. 3. Battery performance: was the battery well-utilized this week? Mention charge/discharge totals and any standout days. 4. Tip of the week: one specific, practical recommendation based on THIS week's patterns to save more energy or money. Rules: Use actual day names and numbers. Keep each bullet to 1-2 sentences. Write for a homeowner, not an engineer. Do NOT use asterisks or any formatting marks. IMPORTANT: Write your entire response in {LanguageName(language)}. Daily data (kWh): {dayLines} Totals: PV={current.TotalPvProduction:F1} Load={current.TotalConsumption:F1} GridIn={current.TotalGridImport:F1} GridOut={current.TotalGridExport:F1} BattIn={current.TotalBatteryCharged:F1} BattOut={current.TotalBatteryDischarged:F1} Solar used at home={solarSavings} kWh ({selfSufficiency}% of consumption covered by solar) Battery-eff={batteryEfficiency}% {comparison}"; try { var requestBody = new { model = "mistral-small-latest", messages = new[] { new { role = "user", content = prompt } }, max_tokens = 350, temperature = 0.3 }; 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)) { var insight = content.Trim(); InsightCache.TryAdd(cacheKey, insight); Console.WriteLine($"[WeeklyReportService] AI insight generated ({insight.Length} chars)."); return insight; } } catch (Exception ex) { Console.Error.WriteLine($"[WeeklyReportService] Mistral error: {ex.Message}"); } return "AI insight could not be generated at this time."; } }