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

554 lines
29 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

using System.Text.RegularExpressions;
using Flurl.Http;
using InnovEnergy.App.Backend.Database;
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/";
/// <summary>
/// Returns xlsx files for the given installation whose date range overlaps [rangeStart, rangeEnd].
/// Filename pattern: {installationId}_MMDD_MMDD.xlsx (e.g. "848_0302_0308.xlsx")
/// Falls back to all files if filenames can't be parsed.
/// </summary>
public static List<string> GetRelevantXlsxFiles(long installationId, DateOnly rangeStart, DateOnly rangeEnd)
{
if (!Directory.Exists(TmpReportDir))
return new List<string>();
var allFiles = Directory.GetFiles(TmpReportDir, $"{installationId}*.xlsx").OrderBy(f => f).ToList();
if (allFiles.Count == 0)
return allFiles;
// Try to filter by filename date range; fall back to all files if parsing fails
var year = rangeStart.Year;
var filtered = new List<string>();
foreach (var file in allFiles)
{
var name = Path.GetFileNameWithoutExtension(file);
// Match pattern: {id}_MMDD_MMDD
var match = Regex.Match(name, @"_(\d{4})_(\d{4})$");
if (!match.Success)
{
// Can't parse filename — include it to be safe
filtered.Add(file);
continue;
}
var startStr = match.Groups[1].Value; // "0302"
var endStr = match.Groups[2].Value; // "0308"
if (!DateOnly.TryParseExact($"{year}-{startStr[..2]}-{startStr[2..]}", "yyyy-MM-dd", out var fileStart) ||
!DateOnly.TryParseExact($"{year}-{endStr[..2]}-{endStr[2..]}", "yyyy-MM-dd", out var fileEnd))
{
filtered.Add(file); // Can't parse — include to be safe
continue;
}
// Include if date ranges overlap
if (fileStart <= rangeEnd && fileEnd >= rangeStart)
filtered.Add(file);
}
return filtered;
}
// ── Calendar Week Helpers ──────────────────────────────────────────
/// <summary>
/// Returns the last completed calendar week (MonSun).
/// Example: called on Wednesday 2026-03-11 → returns (2026-03-02, 2026-03-08).
/// </summary>
public static (DateOnly Monday, DateOnly Sunday) LastCalendarWeek()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
// DayOfWeek: Sun=0, Mon=1, ..., Sat=6
// daysSinceMonday: Mon=0, Tue=1, ..., Sun=6
var daysSinceMonday = ((Int32)today.DayOfWeek + 6) % 7;
var thisMonday = today.AddDays(-daysSinceMonday);
var lastMonday = thisMonday.AddDays(-7);
var lastSunday = thisMonday.AddDays(-1);
return (lastMonday, lastSunday);
}
/// <summary>
/// Returns the calendar week before last (for comparison).
/// </summary>
private static (DateOnly Monday, DateOnly Sunday) PreviousCalendarWeek()
{
var (lastMon, _) = LastCalendarWeek();
return (lastMon.AddDays(-7), lastMon.AddDays(-1));
}
// ── Report Generation ──────────────────────────────────────────────
/// <summary>
/// Generates a full weekly report for the given installation.
/// Data source priority:
/// 1. DailyEnergyRecord rows in SQLite (populated by DailyIngestionService)
/// 2. S3 fallback — ingests missing dates into SQLite via IngestDateRangeAsync, then retries
/// 3. xlsx file fallback (legacy, if both DB and S3 are empty)
/// Cache is keyed to the calendar week — invalidated when the week changes.
/// Pass weekStartOverride to generate a report for a specific historical week (disables cache).
/// </summary>
public static async Task<WeeklyReportResponse> GenerateReportAsync(
long installationId, string installationName, string language = "en",
DateOnly? weekStartOverride = null)
{
DateOnly curMon, curSun, prevMon, prevSun;
if (weekStartOverride.HasValue)
{
// Debug/backfill mode: use the provided Monday as the week start
curMon = weekStartOverride.Value;
curSun = curMon.AddDays(6);
prevMon = curMon.AddDays(-7);
prevSun = curMon.AddDays(-1);
}
else
{
(curMon, curSun) = LastCalendarWeek();
(prevMon, prevSun) = PreviousCalendarWeek();
}
// 1. Load daily records from SQLite
var currentWeekDays = Db.GetDailyRecords(installationId, curMon, curSun)
.Select(ToDailyEnergyData).ToList();
var previousWeekDays = Db.GetDailyRecords(installationId, prevMon, prevSun)
.Select(ToDailyEnergyData).ToList();
// 2. S3 fallback: if DB empty, try ingesting from S3 into SQLite, then retry
if (currentWeekDays.Count == 0)
{
Console.WriteLine($"[WeeklyReportService] DB empty for week {curMon:yyyy-MM-dd}, trying S3 ingestion...");
await DailyIngestionService.IngestDateRangeAsync(installationId, prevMon, curSun);
currentWeekDays = Db.GetDailyRecords(installationId, curMon, curSun)
.Select(ToDailyEnergyData).ToList();
previousWeekDays = Db.GetDailyRecords(installationId, prevMon, prevSun)
.Select(ToDailyEnergyData).ToList();
}
// 3. xlsx fallback: if still empty after S3, parse xlsx on the fly (legacy)
if (currentWeekDays.Count == 0)
{
var relevantFiles = GetRelevantXlsxFiles(installationId, prevMon, curSun);
if (relevantFiles.Count > 0)
{
Console.WriteLine($"[WeeklyReportService] S3 empty, falling back to {relevantFiles.Count} xlsx file(s).");
var allDaysParsed = relevantFiles.SelectMany(p => ExcelDataParser.Parse(p)).ToList();
currentWeekDays = allDaysParsed
.Where(d => { var date = DateOnly.Parse(d.Date); return date >= curMon && date <= curSun; })
.ToList();
previousWeekDays = allDaysParsed
.Where(d => { var date = DateOnly.Parse(d.Date); return date >= prevMon && date <= prevSun; })
.ToList();
}
}
if (currentWeekDays.Count == 0)
throw new InvalidOperationException(
$"No energy data available for week {curMon:yyyy-MM-dd}{curSun:yyyy-MM-dd}. " +
"Upload an xlsx file or wait for daily ingestion.");
// 4. Load hourly records from SQLite for behavioral analysis
// (S3 ingestion above already populated hourly records if available)
var currentHourlyData = Db.GetHourlyRecords(installationId, curMon, curSun)
.Select(ToHourlyEnergyData).ToList();
// 4b. xlsx fallback for hourly data
if (currentHourlyData.Count == 0)
{
var relevantFiles = GetRelevantXlsxFiles(installationId, curMon, curSun);
if (relevantFiles.Count > 0)
{
Console.WriteLine($"[WeeklyReportService] Hourly DB empty for week {curMon:yyyy-MM-dd}, falling back to {relevantFiles.Count} xlsx file(s).");
currentHourlyData = relevantFiles
.SelectMany(p => ExcelDataParser.ParseHourly(p))
.Where(h => { var date = DateOnly.FromDateTime(h.DateTime); return date >= curMon && date <= curSun; })
.ToList();
Console.WriteLine($"[WeeklyReportService] Parsed {currentHourlyData.Count} hourly records from xlsx.");
}
}
// 4. Get installation location for weather forecast
var installation = Db.GetInstallationById(installationId);
var location = installation?.Location;
var country = installation?.Country;
var region = installation?.Region;
Console.WriteLine($"[WeeklyReportService] Installation {installationId}: Location='{location}', Region='{region}', Country='{country}', HourlyRecords={currentHourlyData.Count}");
return await GenerateReportFromDataAsync(
currentWeekDays, previousWeekDays, currentHourlyData, installationName, language,
curMon, curSun, location, country, region);
}
// ── Conversion helpers ─────────────────────────────────────────────
private static DailyEnergyData ToDailyEnergyData(DailyEnergyRecord r) => new()
{
Date = r.Date,
PvProduction = r.PvProduction,
LoadConsumption = r.LoadConsumption,
GridImport = r.GridImport,
GridExport = r.GridExport,
BatteryCharged = r.BatteryCharged,
BatteryDischarged = r.BatteryDischarged,
};
private static HourlyEnergyData ToHourlyEnergyData(HourlyEnergyRecord r) => new()
{
DateTime = DateTime.ParseExact(r.DateHour, "yyyy-MM-dd HH", null),
Hour = r.Hour,
DayOfWeek = r.DayOfWeek,
IsWeekend = r.IsWeekend,
PvKwh = r.PvKwh,
LoadKwh = r.LoadKwh,
GridImportKwh = r.GridImportKwh,
BatteryChargedKwh = r.BatteryChargedKwh,
BatteryDischargedKwh = r.BatteryDischargedKwh,
BattSoC = r.BattSoC,
};
/// <summary>
/// Core report generation. Accepts pre-split current/previous week data and hourly intervals.
/// weekStart/weekEnd are used to set PeriodStart/PeriodEnd on the response.
/// </summary>
public static async Task<WeeklyReportResponse> GenerateReportFromDataAsync(
List<DailyEnergyData> currentWeekDays,
List<DailyEnergyData> previousWeekDays,
List<HourlyEnergyData> currentHourlyData,
string installationName,
string language = "en",
DateOnly? weekStart = null,
DateOnly? weekEnd = null,
string? location = null,
string? country = null,
string? region = null)
{
currentWeekDays = currentWeekDays.OrderBy(d => d.Date).ToList();
previousWeekDays = previousWeekDays.OrderBy(d => d.Date).ToList();
var currentSummary = Summarize(currentWeekDays);
var previousSummary = previousWeekDays.Count > 0 ? Summarize(previousWeekDays) : null;
// 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);
// Behavioral pattern from hourly data (pure C# — no AI)
var behavior = BehaviorAnalyzer.Analyze(currentHourlyData);
// Pre-computed savings — single source of truth for UI and AI
const double ElectricityPriceCHF = 0.39;
var totalEnergySaved = Math.Round(currentSummary.TotalConsumption - currentSummary.TotalGridImport, 1);
var totalSavingsCHF = Math.Round(totalEnergySaved * ElectricityPriceCHF, 0);
var avgDailyConsumption = currentWeekDays.Count > 0 ? currentSummary.TotalConsumption / currentWeekDays.Count : 0;
var daysEquivalent = avgDailyConsumption > 0 ? Math.Round(totalEnergySaved / avgDailyConsumption, 1) : 0;
// AI insight combining daily facts + behavioral pattern
var aiInsight = await GetAiInsightAsync(
currentWeekDays, currentSummary, previousSummary,
selfSufficiency, totalEnergySaved, totalSavingsCHF,
behavior, installationName, language, location, country, region);
// Compute data availability — which days of the week are missing
var availableDates = currentWeekDays.Select(d => d.Date).ToHashSet();
var missingDates = new List<String>();
if (weekStart.HasValue && weekEnd.HasValue)
{
for (var d = weekStart.Value; d <= weekEnd.Value; d = d.AddDays(1))
{
var iso = d.ToString("yyyy-MM-dd");
if (!availableDates.Contains(iso))
missingDates.Add(iso);
}
}
return new WeeklyReportResponse
{
InstallationName = installationName,
PeriodStart = weekStart?.ToString("yyyy-MM-dd") ?? currentWeekDays.First().Date,
PeriodEnd = weekEnd?.ToString("yyyy-MM-dd") ?? currentWeekDays.Last().Date,
CurrentWeek = currentSummary,
PreviousWeek = previousSummary,
TotalEnergySaved = totalEnergySaved,
TotalSavingsCHF = totalSavingsCHF,
DaysEquivalent = daysEquivalent,
SelfSufficiencyPercent = selfSufficiency,
SelfConsumptionPercent = selfConsumption,
BatteryEfficiencyPercent = batteryEfficiency,
GridDependencyPercent = gridDependency,
PvChangePercent = pvChange,
ConsumptionChangePercent = consumptionChange,
GridImportChangePercent = gridImportChange,
DailyData = currentWeekDays,
Behavior = behavior,
AiInsight = aiInsight,
DaysAvailable = currentWeekDays.Count,
DaysExpected = 7,
MissingDates = missingDates,
};
}
private static WeeklySummary Summarize(List<DailyEnergyData> 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 string FormatHour(int hour) => $"{hour:D2}:00";
private static string FormatHourSlot(int hour) => $"{hour:D2}:00{hour + 1:D2}:00";
private static async Task<string> GetAiInsightAsync(
List<DailyEnergyData> currentWeek,
WeeklySummary current,
WeeklySummary? previous,
double selfSufficiency,
double totalEnergySaved,
double totalSavingsCHF,
BehavioralPattern behavior,
string installationName,
string language = "en",
string? location = null,
string? country = null,
string? region = null)
{
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).";
}
// Fetch weather forecast for the installation's location
var forecast = await WeatherService.GetForecastAsync(location, country, region);
var weatherBlock = forecast != null ? "\n" + WeatherService.FormatForPrompt(forecast) + "\n" : "";
Console.WriteLine($"[WeeklyReportService] Weather forecast: {(forecast != null ? $"{forecast.Count} days fetched" : "SKIPPED (no location or API error)")}");
if (forecast != null) Console.WriteLine($"[WeeklyReportService] Weather block:\n{weatherBlock}");
const double ElectricityPriceCHF = 0.39;
// Detect which components are present
var hasPv = currentWeek.Sum(d => d.PvProduction) > 0.5;
var hasBattery = currentWeek.Sum(d => d.BatteryCharged) > 0.5
|| currentWeek.Sum(d => d.BatteryDischarged) > 0.5;
var hasGrid = currentWeek.Sum(d => d.GridImport) > 0.5;
var bestDay = currentWeek.OrderByDescending(d => d.PvProduction).First();
var worstDay = currentWeek.OrderBy(d => d.PvProduction).First();
var bestDayName = DateTime.Parse(bestDay.Date).ToString("dddd");
var worstDayName = DateTime.Parse(worstDay.Date).ToString("dddd");
var topBattDay = currentWeek.OrderByDescending(d => d.BatteryCharged).First();
var topBattDayName = DateTime.Parse(topBattDay.Date).ToString("dddd");
// Check if we have meaningful hourly/behavioral data
var hasBehavior = behavior.AvgPeakLoadKwh > 0 || behavior.AvgPeakSolarKwh > 0;
// Behavioral facts as compact lines (only when hourly data exists)
var peakSolarWindow = FormatHour(behavior.PeakSolarHour) + "" + FormatHour(behavior.PeakSolarEndHour);
var avoidableSavingsCHF = Math.Round(behavior.AvoidableGridKwh * ElectricityPriceCHF, 0);
var battDepleteLine = hasBattery
? (behavior.AvgBatteryDepletedHour >= 0
? $"Battery typically depletes below 20% during {FormatHourSlot(behavior.AvgBatteryDepletedHour)}."
: "Battery stayed above 20% SoC every night this week.")
: "";
var weekdayWeekendLine = behavior.WeekendAvgDailyLoad > 0
? $"Weekday avg load: {behavior.WeekdayAvgDailyLoad} kWh/day. Weekend avg: {behavior.WeekendAvgDailyLoad} kWh/day."
: $"Weekday avg load: {behavior.WeekdayAvgDailyLoad} kWh/day.";
// Build conditional fact lines
var pvDailyFact = hasPv
? $"- PV: total {current.TotalPvProduction:F1} kWh this week. Best day: {bestDayName} ({bestDay.PvProduction:F1} kWh), worst: {worstDayName} ({worstDay.PvProduction:F1} kWh). Solar covered {selfSufficiency}% of consumption."
: "";
var battDailyFact = hasBattery
? $"- Battery: {current.TotalBatteryCharged:F1} kWh charged, {current.TotalBatteryDischarged:F1} kWh discharged. Most active day: {topBattDayName} ({topBattDay.BatteryCharged:F1} kWh charged)."
: "";
var gridDailyFact = hasGrid
? $"- Grid import: {current.TotalGridImport:F1} kWh total this week."
: "";
// Behavioral section — only include when hourly data exists
var behavioralSection = "";
if (hasBehavior)
{
var pvBehaviorLines = hasPv ? $@"
- Solar active window: {peakSolarWindow}; peak hour: {FormatHourSlot(behavior.PeakSolarHour)}, avg {behavior.AvgPeakSolarKwh} kWh during that hour
- Grid imported while solar was active: {behavior.AvoidableGridKwh} kWh = {avoidableSavingsCHF} CHF that could have been avoided" : "";
var gridBehaviorLine = hasGrid
? $"- Highest grid-import hour: {FormatHourSlot(behavior.HighestGridImportHour)}, avg {behavior.AvgGridImportAtPeakHour} kWh during that hour"
: "";
var battBehaviorLine = !string.IsNullOrEmpty(battDepleteLine) ? $"- {battDepleteLine}" : "";
behavioralSection = $@"
BEHAVIORAL PATTERN (from hourly data this week):
- Peak household load: {FormatHourSlot(behavior.PeakLoadHour)}, avg {behavior.AvgPeakLoadKwh} kWh during that hour
- {weekdayWeekendLine}{pvBehaviorLines}
{gridBehaviorLine}
{battBehaviorLine}";
}
// Build conditional instructions
var instruction1 = $"1. Energy savings: Write 12 sentences. Say that this week, thanks to sodistore home, the customer avoided buying {totalEnergySaved} kWh from the grid, saving {totalSavingsCHF} CHF (at {ElectricityPriceCHF} CHF/kWh). Use these exact numbers — do not recalculate or change them.";
var instruction2 = hasPv
? $"2. Solar performance: Comment on the best and worst solar day this week and the likely weather reason."
: hasGrid
? $"2. Grid usage: Comment on the {current.TotalGridImport:F1} kWh drawn from the grid this week."
: "2. Consumption pattern: Comment on the weekday vs weekend load pattern.";
var instruction3 = hasBattery
? $"3. Battery performance: Use the daily facts. Keep it simple for a homeowner."
: "3. Consumption pattern: Comment on the peak load time and weekday vs weekend usage.";
// Instruction 4 — adapts based on whether we have behavioral data
string instruction4;
if (hasBehavior && hasPv)
instruction4 = $"4. Smart action for next week: Write exactly 2 sentences. Sentence 1: point out the timing mismatch using exact numbers — peak household load is during {FormatHourSlot(behavior.PeakLoadHour)} ({behavior.AvgPeakLoadKwh} kWh) but solar peaks during {FormatHourSlot(behavior.PeakSolarHour)} ({behavior.AvgPeakSolarKwh} kWh), with solar active from {peakSolarWindow}. Sentence 2: suggest shifting energy-intensive appliances (such as washing machine, dishwasher, heat pump, or EV charger if applicable) to run during the solar window {peakSolarWindow} — do not assume which specific device the customer has.";
else if (hasBehavior && hasGrid)
instruction4 = $"4. Smart action for next week: Write exactly 2 sentences. Sentence 1: state that the peak grid-import hour is {FormatHourSlot(behavior.HighestGridImportHour)} ({behavior.AvgGridImportAtPeakHour} kWh avg). Sentence 2: suggest one action to reduce grid use during that hour — shifting energy-intensive appliances (washing machine, dishwasher, heat pump, EV charger) away from that time.";
else
instruction4 = "4. Smart action for next week: Based on this week's daily energy patterns, suggest one practical tip to maximize self-consumption and reduce grid dependency.";
// Instruction 5 — weather outlook with pattern-based predictions
var hasWeather = forecast != null;
var bulletCount = hasWeather ? 5 : 4;
var instruction5 = "";
if (hasWeather && hasPv)
{
// Compute avg daily PV production this week for reference
var avgDailyPv = currentWeek.Count > 0 ? Math.Round(currentWeek.Average(d => d.PvProduction), 1) : 0;
var bestDayPv = Math.Round(bestDay.PvProduction, 1);
var worstDayPv = Math.Round(worstDay.PvProduction, 1);
// Classify forecast days by sunshine potential
var sunnyDays = forecast.Where(d => d.SunshineHours >= 7).Select(d => DateTime.Parse(d.Date).ToString("dddd")).ToList();
var cloudyDays = forecast.Where(d => d.SunshineHours < 3).Select(d => DateTime.Parse(d.Date).ToString("dddd")).ToList();
var totalForecastSunshine = Math.Round(forecast.Sum(d => d.SunshineHours), 1);
var patternContext = $"This week the installation averaged {avgDailyPv} kWh/day PV (best: {bestDayPv} kWh on {bestDayName}, worst: {worstDayPv} kWh on {worstDayName}). ";
if (sunnyDays.Count > 0)
patternContext += $"Next week, sunny days ({string.Join(", ", sunnyDays)}) should produce above average (~{avgDailyPv}+ kWh/day). ";
if (cloudyDays.Count > 0)
patternContext += $"Cloudy/rainy days ({string.Join(", ", cloudyDays)}) will likely produce below average (~{worstDayPv} kWh/day or less). ";
patternContext += $"Total forecast sunshine next week: {totalForecastSunshine}h.";
instruction5 = $@"5. Weather outlook: {patternContext} Write 2-3 concise sentences. (1) Name the best solar days next week and estimate production based on this week's pattern. (2) Recommend which specific days are best for running energy-heavy appliances (washing machine, dishwasher, EV charging) to maximize free solar energy. (3) If rainy days follow sunny days, suggest front-loading heavy usage onto the sunny days before the rain arrives. IMPORTANT: Do NOT suggest ""reducing consumption"" in the evening — people need electricity for cooking, lighting, and daily life. Instead, focus on SHIFTING discretionary loads (laundry, dishwasher, EV) to the best solar days. The battery system handles grid charging automatically — do not tell the customer to manage battery charging.";
}
else if (hasWeather)
{
instruction5 = @"5. Weather outlook: Summarize the coming week's weather in 1-2 sentences. Mention which days have the most sunshine and suggest those as the best days to run energy-heavy appliances (laundry, dishwasher). Do NOT suggest reducing evening consumption — focus on shifting discretionary loads to sunny days.";
}
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
Write {bulletCount} 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.
CRITICAL: All numbers below are pre-calculated. Use these values as-is — do not recalculate, round differently, or change any number.
SYSTEM COMPONENTS: PV={hasPv}, Battery={hasBattery}, Grid={hasGrid}
DAILY FACTS:
- Total consumption: {current.TotalConsumption:F1} kWh this week. Self-sufficiency: {selfSufficiency}%.
{pvDailyFact}
{battDailyFact}
{gridDailyFact}
{behavioralSection}
{weatherBlock}
INSTRUCTIONS:
{instruction1}
{instruction2}
{instruction3}
{instruction4}
{instruction5}
Rules: Write for a homeowner, not an engineer. Do NOT use asterisks or any formatting marks. Only describe components that exist (PV={hasPv}, Battery={hasBattery}, Grid={hasGrid}). Do NOT add any closing remark, summary sentence, or motivational phrase after the {bulletCount} bullet points (e.g. no 'Das ist ein guter Fortschritt', 'Keep it up', 'Good progress', etc.). Write exactly {bulletCount} bullet points — nothing before, nothing after.
IMPORTANT: Write your entire response in {LanguageName(language)}.";
try
{
var requestBody = new
{
model = "mistral-small-latest",
messages = new[] { new { role = "user", content = prompt } },
max_tokens = 600,
temperature = 0.3
};
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))
{
var insight = content.Trim();
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.";
}
}