restructured data pipeline for report system and updated the way to create monthly and yearly report

This commit is contained in:
Yinyin Liu 2026-03-02 12:49:46 +01:00
parent 78b9c2dc72
commit 1761914f24
13 changed files with 886 additions and 177 deletions

View File

@ -901,7 +901,8 @@ public class Controller : ControllerBase
/// Returns JSON with daily data, weekly totals, ratios, and AI insight. /// Returns JSON with daily data, weekly totals, ratios, and AI insight.
/// </summary> /// </summary>
[HttpGet(nameof(GetWeeklyReport))] [HttpGet(nameof(GetWeeklyReport))]
public async Task<ActionResult<WeeklyReportResponse>> GetWeeklyReport(Int64 installationId, Token authToken, string? language = null) public async Task<ActionResult<WeeklyReportResponse>> GetWeeklyReport(
Int64 installationId, Token authToken, String? language = null, String? weekStart = null)
{ {
var user = Db.GetSession(authToken)?.User; var user = Db.GetSession(authToken)?.User;
if (user == null) if (user == null)
@ -911,17 +912,23 @@ public class Controller : ControllerBase
if (installation is null || !user.HasAccessTo(installation)) if (installation is null || !user.HasAccessTo(installation))
return Unauthorized(); return Unauthorized();
var filePath = Environment.CurrentDirectory + "/tmp_report/" + installationId + ".xlsx"; // Parse optional weekStart override (must be a Monday; any valid date accepted for flexibility)
if (!System.IO.File.Exists(filePath)) DateOnly? weekStartDate = null;
return NotFound($"No report data file found. Please place the Excel export at: tmp_report/{installationId}.xlsx"); if (!String.IsNullOrEmpty(weekStart))
{
if (!DateOnly.TryParseExact(weekStart, "yyyy-MM-dd", out var parsed))
return BadRequest("weekStart must be in yyyy-MM-dd format.");
weekStartDate = parsed;
}
try try
{ {
var lang = language ?? user.Language ?? "en"; var lang = language ?? user.Language ?? "en";
var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.InstallationName, lang); var report = await WeeklyReportService.GenerateReportAsync(
installationId, installation.InstallationName, lang, weekStartDate);
// Persist weekly summary for future monthly aggregation (idempotent) // Persist weekly summary and seed AiInsightCache for this language
ReportAggregationService.SaveWeeklySummary(installationId, report); ReportAggregationService.SaveWeeklySummary(installationId, report, lang);
return Ok(report); return Ok(report);
} }
@ -991,7 +998,8 @@ public class Controller : ControllerBase
} }
[HttpGet(nameof(GetMonthlyReports))] [HttpGet(nameof(GetMonthlyReports))]
public ActionResult<List<MonthlyReportSummary>> GetMonthlyReports(Int64 installationId, Token authToken) public async Task<ActionResult<List<MonthlyReportSummary>>> GetMonthlyReports(
Int64 installationId, Token authToken, String? language = null)
{ {
var user = Db.GetSession(authToken)?.User; var user = Db.GetSession(authToken)?.User;
if (user == null) if (user == null)
@ -1001,11 +1009,16 @@ public class Controller : ControllerBase
if (installation is null || !user.HasAccessTo(installation)) if (installation is null || !user.HasAccessTo(installation))
return Unauthorized(); return Unauthorized();
return Ok(Db.GetMonthlyReports(installationId)); var lang = language ?? user.Language ?? "en";
var reports = Db.GetMonthlyReports(installationId);
foreach (var report in reports)
report.AiInsight = await ReportAggregationService.GetOrGenerateMonthlyInsightAsync(report, lang);
return Ok(reports);
} }
[HttpGet(nameof(GetYearlyReports))] [HttpGet(nameof(GetYearlyReports))]
public ActionResult<List<YearlyReportSummary>> GetYearlyReports(Int64 installationId, Token authToken) public async Task<ActionResult<List<YearlyReportSummary>>> GetYearlyReports(
Int64 installationId, Token authToken, String? language = null)
{ {
var user = Db.GetSession(authToken)?.User; var user = Db.GetSession(authToken)?.User;
if (user == null) if (user == null)
@ -1015,12 +1028,16 @@ public class Controller : ControllerBase
if (installation is null || !user.HasAccessTo(installation)) if (installation is null || !user.HasAccessTo(installation))
return Unauthorized(); return Unauthorized();
return Ok(Db.GetYearlyReports(installationId)); var lang = language ?? user.Language ?? "en";
var reports = Db.GetYearlyReports(installationId);
foreach (var report in reports)
report.AiInsight = await ReportAggregationService.GetOrGenerateYearlyInsightAsync(report, lang);
return Ok(reports);
} }
/// <summary> /// <summary>
/// Manually trigger monthly aggregation for an installation. /// Manually trigger monthly aggregation for an installation.
/// Aggregates weekly reports for the specified year/month into a monthly report. /// Computes monthly report from daily records for the specified year/month.
/// </summary> /// </summary>
[HttpPost(nameof(TriggerMonthlyAggregation))] [HttpPost(nameof(TriggerMonthlyAggregation))]
public async Task<ActionResult> TriggerMonthlyAggregation(Int64 installationId, Int32 year, Int32 month, Token authToken) public async Task<ActionResult> TriggerMonthlyAggregation(Int64 installationId, Int32 year, Int32 month, Token authToken)
@ -1039,11 +1056,11 @@ public class Controller : ControllerBase
try try
{ {
var lang = user.Language ?? "en"; var lang = user.Language ?? "en";
var weekCount = await ReportAggregationService.TriggerMonthlyAggregationAsync(installationId, year, month, lang); var dayCount = await ReportAggregationService.TriggerMonthlyAggregationAsync(installationId, year, month, lang);
if (weekCount == 0) if (dayCount == 0)
return NotFound($"No weekly reports found for {year}-{month:D2}."); return NotFound($"No daily records found for {year}-{month:D2}.");
return Ok(new { message = $"Monthly report created from {weekCount} weekly reports for {year}-{month:D2}." }); return Ok(new { message = $"Monthly report created from {dayCount} daily records for {year}-{month:D2}." });
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -1083,6 +1100,119 @@ public class Controller : ControllerBase
} }
} }
/// <summary>
/// Manually trigger xlsx ingestion for all SodioHome installations.
/// Scans tmp_report/ for all matching xlsx files and ingests any new days.
/// </summary>
[HttpPost(nameof(IngestAllDailyData))]
public async Task<ActionResult> IngestAllDailyData(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user == null)
return Unauthorized();
try
{
await DailyIngestionService.IngestAllInstallationsAsync();
return Ok(new { message = "Daily data ingestion triggered for all installations." });
}
catch (Exception ex)
{
Console.Error.WriteLine($"[IngestAllDailyData] Error: {ex.Message}");
return BadRequest($"Failed to ingest: {ex.Message}");
}
}
/// <summary>
/// Manually trigger xlsx ingestion for one installation.
/// Parses tmp_report/{installationId}*.xlsx and stores any new days in DailyEnergyRecord.
/// </summary>
[HttpPost(nameof(IngestDailyData))]
public async Task<ActionResult> IngestDailyData(Int64 installationId, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user == null)
return Unauthorized();
var installation = Db.GetInstallationById(installationId);
if (installation is null || !user.HasAccessTo(installation))
return Unauthorized();
try
{
await DailyIngestionService.IngestInstallationAsync(installationId);
return Ok(new { message = $"Daily data ingestion triggered for installation {installationId}." });
}
catch (Exception ex)
{
Console.Error.WriteLine($"[IngestDailyData] Error: {ex.Message}");
return BadRequest($"Failed to ingest: {ex.Message}");
}
}
// ── Debug / Inspection Endpoints ──────────────────────────────────
/// <summary>
/// Returns the stored DailyEnergyRecord rows for an installation and date range.
/// Use this to verify that xlsx ingestion worked correctly before generating reports.
/// </summary>
[HttpGet(nameof(GetDailyRecords))]
public ActionResult<List<DailyEnergyRecord>> GetDailyRecords(
Int64 installationId, String from, String to, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user == null)
return Unauthorized();
var installation = Db.GetInstallationById(installationId);
if (installation is null || !user.HasAccessTo(installation))
return Unauthorized();
if (!DateOnly.TryParseExact(from, "yyyy-MM-dd", out var fromDate) ||
!DateOnly.TryParseExact(to, "yyyy-MM-dd", out var toDate))
return BadRequest("from and to must be in yyyy-MM-dd format.");
var records = Db.GetDailyRecords(installationId, fromDate, toDate);
return Ok(new { count = records.Count, records });
}
/// <summary>
/// Deletes DailyEnergyRecord rows for an installation in the given date range.
/// Safe to use during testing — only removes daily records, not report summaries.
/// Allows re-ingesting the same xlsx files after correcting data.
/// </summary>
[HttpDelete(nameof(DeleteDailyRecords))]
public ActionResult DeleteDailyRecords(
Int64 installationId, String from, String to, Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user == null)
return Unauthorized();
var installation = Db.GetInstallationById(installationId);
if (installation is null || !user.HasAccessTo(installation))
return Unauthorized();
if (!DateOnly.TryParseExact(from, "yyyy-MM-dd", out var fromDate) ||
!DateOnly.TryParseExact(to, "yyyy-MM-dd", out var toDate))
return BadRequest("from and to must be in yyyy-MM-dd format.");
var fromStr = fromDate.ToString("yyyy-MM-dd");
var toStr = toDate.ToString("yyyy-MM-dd");
var toDelete = Db.DailyRecords
.Where(r => r.InstallationId == installationId)
.ToList()
.Where(r => String.Compare(r.Date, fromStr, StringComparison.Ordinal) >= 0
&& String.Compare(r.Date, toStr, StringComparison.Ordinal) <= 0)
.ToList();
foreach (var record in toDelete)
Db.DailyRecords.Delete(r => r.Id == record.Id);
Console.WriteLine($"[DeleteDailyRecords] Deleted {toDelete.Count} records for installation {installationId} ({from}{to}).");
return Ok(new { deleted = toDelete.Count, from, to });
}
[HttpPost(nameof(SendMonthlyReportEmail))] [HttpPost(nameof(SendMonthlyReportEmail))]
public async Task<ActionResult> SendMonthlyReportEmail(Int64 installationId, Int32 year, Int32 month, String emailAddress, Token authToken) public async Task<ActionResult> SendMonthlyReportEmail(Int64 installationId, Int32 year, Int32 month, String emailAddress, Token authToken)
{ {
@ -1101,6 +1231,7 @@ public class Controller : ControllerBase
try try
{ {
var lang = user.Language ?? "en"; var lang = user.Language ?? "en";
report.AiInsight = await ReportAggregationService.GetOrGenerateMonthlyInsightAsync(report, lang);
await ReportEmailService.SendMonthlyReportEmailAsync(report, installation.InstallationName, emailAddress, lang); await ReportEmailService.SendMonthlyReportEmailAsync(report, installation.InstallationName, emailAddress, lang);
return Ok(new { message = $"Monthly report sent to {emailAddress}" }); return Ok(new { message = $"Monthly report sent to {emailAddress}" });
} }
@ -1129,6 +1260,7 @@ public class Controller : ControllerBase
try try
{ {
var lang = user.Language ?? "en"; var lang = user.Language ?? "en";
report.AiInsight = await ReportAggregationService.GetOrGenerateYearlyInsightAsync(report, lang);
await ReportEmailService.SendYearlyReportEmailAsync(report, installation.InstallationName, emailAddress, lang); await ReportEmailService.SendYearlyReportEmailAsync(report, installation.InstallationName, emailAddress, lang);
return Ok(new { message = $"Yearly report sent to {emailAddress}" }); return Ok(new { message = $"Yearly report sent to {emailAddress}" });
} }
@ -1140,7 +1272,8 @@ public class Controller : ControllerBase
} }
[HttpGet(nameof(GetWeeklyReportSummaries))] [HttpGet(nameof(GetWeeklyReportSummaries))]
public ActionResult<List<WeeklyReportSummary>> GetWeeklyReportSummaries(Int64 installationId, Int32 year, Int32 month, Token authToken) public async Task<ActionResult<List<WeeklyReportSummary>>> GetWeeklyReportSummaries(
Int64 installationId, Int32 year, Int32 month, Token authToken, String? language = null)
{ {
var user = Db.GetSession(authToken)?.User; var user = Db.GetSession(authToken)?.User;
if (user == null) if (user == null)
@ -1150,7 +1283,11 @@ public class Controller : ControllerBase
if (installation is null || !user.HasAccessTo(installation)) if (installation is null || !user.HasAccessTo(installation))
return Unauthorized(); return Unauthorized();
return Ok(Db.GetWeeklyReportsForMonth(installationId, year, month)); var lang = language ?? user.Language ?? "en";
var summaries = Db.GetWeeklyReportsForMonth(installationId, year, month);
foreach (var s in summaries)
s.AiInsight = await ReportAggregationService.GetOrGenerateWeeklyInsightAsync(s, lang);
return Ok(summaries);
} }
[HttpPut(nameof(UpdateFolder))] [HttpPut(nameof(UpdateFolder))]

View File

@ -0,0 +1,29 @@
using SQLite;
namespace InnovEnergy.App.Backend.DataTypes;
/// <summary>
/// Per-language AI insight cache for weekly, monthly, and yearly report summaries.
/// Keyed by (ReportType, ReportId, Language) — generated once on first request per language,
/// then reused for UI reads and email sends. Never store language-specific text in the
/// summary tables themselves; always go through this cache.
/// </summary>
public class AiInsightCache
{
[PrimaryKey, AutoIncrement]
public Int64 Id { get; set; }
/// <summary>"weekly" | "monthly" | "yearly"</summary>
[Indexed]
public String ReportType { get; set; } = "";
/// <summary>FK to WeeklyReportSummary.Id / MonthlyReportSummary.Id / YearlyReportSummary.Id</summary>
[Indexed]
public Int64 ReportId { get; set; }
/// <summary>ISO 639-1 language code: "en" | "de" | "fr" | "it"</summary>
public String Language { get; set; } = "en";
public String InsightText { get; set; } = "";
public String CreatedAt { get; set; } = "";
}

View File

@ -0,0 +1,31 @@
using SQLite;
namespace InnovEnergy.App.Backend.DataTypes;
/// <summary>
/// Raw daily energy totals for one installation and calendar day.
/// Source of truth for weekly and monthly report generation.
/// Populated by DailyIngestionService from xlsx (current) or S3 (future).
/// Retention: 1 year (cleaned up annually on Jan 2).
/// </summary>
public class DailyEnergyRecord
{
[PrimaryKey, AutoIncrement]
public Int64 Id { get; set; }
[Indexed]
public Int64 InstallationId { get; set; }
// ISO date string: "YYYY-MM-DD"
public String Date { get; set; } = "";
// Energy totals (kWh) — cumulative for the full calendar day
public Double PvProduction { get; set; }
public Double LoadConsumption { get; set; }
public Double GridImport { get; set; }
public Double GridExport { get; set; }
public Double BatteryCharged { get; set; }
public Double BatteryDischarged { get; set; }
public String CreatedAt { get; set; } = "";
}

View File

@ -71,6 +71,8 @@ public static partial class Db
public static Boolean Create(WeeklyReportSummary report) => Insert(report); public static Boolean Create(WeeklyReportSummary report) => Insert(report);
public static Boolean Create(MonthlyReportSummary report) => Insert(report); public static Boolean Create(MonthlyReportSummary report) => Insert(report);
public static Boolean Create(YearlyReportSummary report) => Insert(report); public static Boolean Create(YearlyReportSummary report) => Insert(report);
public static Boolean Create(DailyEnergyRecord record) => Insert(record);
public static Boolean Create(AiInsightCache cache) => Insert(cache);
public static void HandleAction(UserAction newAction) public static void HandleAction(UserAction newAction)
{ {

View File

@ -28,6 +28,8 @@ public static partial class Db
public static TableQuery<WeeklyReportSummary> WeeklyReports => Connection.Table<WeeklyReportSummary>(); public static TableQuery<WeeklyReportSummary> WeeklyReports => Connection.Table<WeeklyReportSummary>();
public static TableQuery<MonthlyReportSummary> MonthlyReports => Connection.Table<MonthlyReportSummary>(); public static TableQuery<MonthlyReportSummary> MonthlyReports => Connection.Table<MonthlyReportSummary>();
public static TableQuery<YearlyReportSummary> YearlyReports => Connection.Table<YearlyReportSummary>(); public static TableQuery<YearlyReportSummary> YearlyReports => Connection.Table<YearlyReportSummary>();
public static TableQuery<DailyEnergyRecord> DailyRecords => Connection.Table<DailyEnergyRecord>();
public static TableQuery<AiInsightCache> AiInsightCaches => Connection.Table<AiInsightCache>();
public static void Init() public static void Init()
@ -57,6 +59,8 @@ public static partial class Db
Connection.CreateTable<WeeklyReportSummary>(); Connection.CreateTable<WeeklyReportSummary>();
Connection.CreateTable<MonthlyReportSummary>(); Connection.CreateTable<MonthlyReportSummary>();
Connection.CreateTable<YearlyReportSummary>(); Connection.CreateTable<YearlyReportSummary>();
Connection.CreateTable<DailyEnergyRecord>();
Connection.CreateTable<AiInsightCache>();
}); });
// One-time migration: normalize legacy long-form language values to ISO codes // One-time migration: normalize legacy long-form language values to ISO codes
@ -97,6 +101,7 @@ public static partial class Db
fileConnection.CreateTable<WeeklyReportSummary>(); fileConnection.CreateTable<WeeklyReportSummary>();
fileConnection.CreateTable<MonthlyReportSummary>(); fileConnection.CreateTable<MonthlyReportSummary>();
fileConnection.CreateTable<YearlyReportSummary>(); fileConnection.CreateTable<YearlyReportSummary>();
fileConnection.CreateTable<DailyEnergyRecord>();
return fileConnection; return fileConnection;
//return CopyDbToMemory(fileConnection); //return CopyDbToMemory(fileConnection);

View File

@ -180,4 +180,57 @@ public static partial class Db
var count = YearlyReports.Delete(r => r.InstallationId == installationId && r.Year == year); var count = YearlyReports.Delete(r => r.InstallationId == installationId && r.Year == year);
if (count > 0) Backup(); if (count > 0) Backup();
} }
/// <summary>
/// Deletes all report records older than 1 year. Called annually on Jan 2
/// after yearly reports are created. Uses fetch-then-delete for string-compared
/// date fields (SQLite-net doesn't support string comparisons in Delete lambdas).
/// </summary>
public static void CleanupOldData()
{
var cutoff = DateOnly.FromDateTime(DateTime.UtcNow).AddYears(-1).ToString("yyyy-MM-dd");
var prevYear = DateTime.UtcNow.Year - 1;
// Daily records older than 1 year
var oldDailyIds = DailyRecords
.ToList()
.Where(r => String.Compare(r.Date, cutoff, StringComparison.Ordinal) < 0)
.Select(r => r.Id)
.ToList();
foreach (var id in oldDailyIds)
DailyRecords.Delete(r => r.Id == id);
// Weekly summaries older than 1 year
var oldWeeklyIds = WeeklyReports
.ToList()
.Where(r => String.Compare(r.PeriodEnd, cutoff, StringComparison.Ordinal) < 0)
.Select(r => r.Id)
.ToList();
foreach (var id in oldWeeklyIds)
WeeklyReports.Delete(r => r.Id == id);
// Monthly summaries older than 1 year
var oldMonthlyIds = MonthlyReports
.ToList()
.Where(r => String.Compare(r.PeriodEnd, cutoff, StringComparison.Ordinal) < 0)
.Select(r => r.Id)
.ToList();
foreach (var id in oldMonthlyIds)
MonthlyReports.Delete(r => r.Id == id);
// Yearly summaries — keep current and previous year only
YearlyReports.Delete(r => r.Year < prevYear);
// AI insight cache entries older than 1 year
var oldCacheIds = AiInsightCaches
.ToList()
.Where(c => String.Compare(c.CreatedAt, cutoff, StringComparison.Ordinal) < 0)
.Select(c => c.Id)
.ToList();
foreach (var id in oldCacheIds)
AiInsightCaches.Delete(c => c.Id == id);
Backup();
Console.WriteLine($"[Db] Cleanup: {oldDailyIds.Count} daily, {oldWeeklyIds.Count} weekly, {oldMonthlyIds.Count} monthly, {oldCacheIds.Count} insight cache records deleted (cutoff {cutoff}).");
}
} }

View File

@ -94,4 +94,41 @@ public static partial class Db
.Where(r => r.InstallationId == installationId) .Where(r => r.InstallationId == installationId)
.OrderByDescending(r => r.Year) .OrderByDescending(r => r.Year)
.ToList(); .ToList();
// ── DailyEnergyRecord Queries ──────────────────────────────────────
/// <summary>
/// Returns daily records for an installation within [from, to] inclusive, ordered by date.
/// </summary>
public static List<DailyEnergyRecord> GetDailyRecords(Int64 installationId, DateOnly from, DateOnly to)
{
var fromStr = from.ToString("yyyy-MM-dd");
var toStr = to.ToString("yyyy-MM-dd");
return DailyRecords
.Where(r => r.InstallationId == installationId)
.ToList()
.Where(r => String.Compare(r.Date, fromStr, StringComparison.Ordinal) >= 0
&& String.Compare(r.Date, toStr, StringComparison.Ordinal) <= 0)
.OrderBy(r => r.Date)
.ToList();
}
/// <summary>
/// Returns true if a daily record already exists for this installation+date (idempotency check).
/// </summary>
public static Boolean DailyRecordExists(Int64 installationId, String date)
=> DailyRecords
.Any(r => r.InstallationId == installationId && r.Date == date);
// ── AiInsightCache Queries ─────────────────────────────────────────
/// <summary>
/// Returns the cached AI insight text for (reportType, reportId, language), or null on miss.
/// </summary>
public static String? GetCachedInsight(String reportType, Int64 reportId, String language)
=> AiInsightCaches
.FirstOrDefault(c => c.ReportType == reportType
&& c.ReportId == reportId
&& c.Language == language)
?.InsightText;
} }

View File

@ -28,7 +28,8 @@ public static class Program
LoadEnvFile(); LoadEnvFile();
DiagnosticService.Initialize(); DiagnosticService.Initialize();
AlarmReviewService.StartDailyScheduler(); AlarmReviewService.StartDailyScheduler();
// ReportAggregationService.StartScheduler(); // TODO: uncomment to enable automatic monthly/yearly aggregation // DailyIngestionService.StartScheduler(); // Phase 2: enable when S3 auto-push is ready
// ReportAggregationService.StartScheduler(); // Phase 2: enable when scheduler should auto-run monthly/yearly
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
RabbitMqManager.InitializeEnvironment(); RabbitMqManager.InitializeEnvironment();

View File

@ -0,0 +1,137 @@
using InnovEnergy.App.Backend.Database;
using InnovEnergy.App.Backend.DataTypes;
namespace InnovEnergy.App.Backend.Services;
/// <summary>
/// Ingests daily energy totals from xlsx files into the DailyEnergyRecord SQLite table.
/// This is the source-of-truth population step for the report pipeline.
///
/// Current data source: xlsx files placed in tmp_report/{installationId}.xlsx
/// Future data source: S3 raw records (replace ExcelDataParser call with S3DailyExtractor)
///
/// Runs automatically at 01:00 UTC daily. Can also be triggered manually via the
/// IngestDailyData API endpoint.
/// </summary>
public static class DailyIngestionService
{
private static readonly String TmpReportDir =
Environment.CurrentDirectory + "/tmp_report/";
private static Timer? _dailyTimer;
/// <summary>
/// Starts the daily scheduler. Call once on app startup.
/// Ingests xlsx data at 01:00 UTC every day.
/// </summary>
public static void StartScheduler()
{
var now = DateTime.UtcNow;
var next = now.Date.AddDays(1).AddHours(1); // 01:00 UTC tomorrow
_dailyTimer = new Timer(
_ =>
{
try
{
IngestAllInstallationsAsync().GetAwaiter().GetResult();
}
catch (Exception ex)
{
Console.Error.WriteLine($"[DailyIngestion] Scheduler error: {ex.Message}");
}
},
null, next - now, TimeSpan.FromDays(1));
Console.WriteLine($"[DailyIngestion] Scheduler started. Next run: {next:yyyy-MM-dd HH:mm} UTC");
}
/// <summary>
/// Ingests xlsx data for all SodioHome installations. Safe to call manually.
/// </summary>
public static async Task IngestAllInstallationsAsync()
{
Console.WriteLine($"[DailyIngestion] Starting ingestion for all SodioHome installations...");
var installations = Db.Installations
.Where(i => i.Product == (Int32)ProductType.SodioHome)
.ToList();
foreach (var installation in installations)
{
try
{
await IngestInstallationAsync(installation.Id);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[DailyIngestion] Failed for installation {installation.Id}: {ex.Message}");
}
}
Console.WriteLine($"[DailyIngestion] Ingestion complete.");
}
/// <summary>
/// Parses all xlsx files matching {installationId}*.xlsx in tmp_report/ and stores
/// any new days as DailyEnergyRecord rows. Supports multiple time-ranged files per
/// installation (e.g. 123_0203_0803.xlsx, 123_0901_1231.xlsx). Idempotent.
/// </summary>
public static async Task IngestInstallationAsync(Int64 installationId)
{
if (!Directory.Exists(TmpReportDir))
{
Console.WriteLine($"[DailyIngestion] tmp_report directory not found, skipping.");
return;
}
var xlsxFiles = Directory.GetFiles(TmpReportDir, $"{installationId}*.xlsx");
if (xlsxFiles.Length == 0)
{
Console.WriteLine($"[DailyIngestion] No xlsx found for installation {installationId}, skipping.");
return;
}
var newCount = 0;
var totalParsed = 0;
foreach (var xlsxPath in xlsxFiles.OrderBy(f => f))
{
List<DailyEnergyData> days;
try
{
days = ExcelDataParser.Parse(xlsxPath);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[DailyIngestion] Failed to parse {Path.GetFileName(xlsxPath)}: {ex.Message}");
continue;
}
totalParsed += days.Count;
foreach (var day in days)
{
if (Db.DailyRecordExists(installationId, day.Date))
continue;
Db.Create(new DailyEnergyRecord
{
InstallationId = installationId,
Date = day.Date,
PvProduction = day.PvProduction,
LoadConsumption = day.LoadConsumption,
GridImport = day.GridImport,
GridExport = day.GridExport,
BatteryCharged = day.BatteryCharged,
BatteryDischarged = day.BatteryDischarged,
CreatedAt = DateTime.UtcNow.ToString("o"),
});
newCount++;
}
}
Console.WriteLine($"[DailyIngestion] Installation {installationId}: {newCount} new day(s) ingested ({totalParsed} total across {xlsxFiles.Length} file(s)).");
await Task.CompletedTask;
}
}

View File

@ -167,7 +167,7 @@ public static class ReportAggregationService
// ── Save Weekly Summary ─────────────────────────────────────────── // ── Save Weekly Summary ───────────────────────────────────────────
public static void SaveWeeklySummary(Int64 installationId, WeeklyReportResponse report) public static void SaveWeeklySummary(Int64 installationId, WeeklyReportResponse report, String language = "en")
{ {
try try
{ {
@ -211,6 +211,18 @@ public static class ReportAggregationService
}; };
Db.Create(summary); Db.Create(summary);
// Seed AiInsightCache so historical reads for this language are free
if (!String.IsNullOrEmpty(summary.AiInsight))
Db.Create(new AiInsightCache
{
ReportType = "weekly",
ReportId = summary.Id,
Language = language,
InsightText = summary.AiInsight,
CreatedAt = summary.CreatedAt,
});
Console.WriteLine($"[ReportAggregation] Saved weekly summary for installation {installationId}, period {report.PeriodStart}{report.PeriodEnd} (replaced {overlapping.Count} overlapping record(s))"); Console.WriteLine($"[ReportAggregation] Saved weekly summary for installation {installationId}, period {report.PeriodStart}{report.PeriodEnd} (replaced {overlapping.Count} overlapping record(s))");
} }
catch (Exception ex) catch (Exception ex)
@ -272,12 +284,14 @@ public static class ReportAggregationService
/// </summary> /// </summary>
public static async Task<Int32> TriggerMonthlyAggregationAsync(Int64 installationId, Int32 year, Int32 month, String language = "en") public static async Task<Int32> TriggerMonthlyAggregationAsync(Int64 installationId, Int32 year, Int32 month, String language = "en")
{ {
var weeklies = Db.GetWeeklyReportsForMonth(installationId, year, month); var first = new DateOnly(year, month, 1);
if (weeklies.Count == 0) var last = first.AddMonths(1).AddDays(-1);
var days = Db.GetDailyRecords(installationId, first, last);
if (days.Count == 0)
return 0; return 0;
await AggregateMonthForInstallation(installationId, year, month, language); await AggregateMonthForInstallation(installationId, year, month, language);
return weeklies.Count; return days.Count;
} }
/// <summary> /// <summary>
@ -300,10 +314,15 @@ public static class ReportAggregationService
var previousMonth = DateTime.Now.AddMonths(-1); var previousMonth = DateTime.Now.AddMonths(-1);
var year = previousMonth.Year; var year = previousMonth.Year;
var month = previousMonth.Month; var month = previousMonth.Month;
var first = new DateOnly(year, month, 1);
var last = first.AddMonths(1).AddDays(-1);
Console.WriteLine($"[ReportAggregation] Running month-end aggregation for {year}-{month:D2}..."); Console.WriteLine($"[ReportAggregation] Running month-end aggregation for {year}-{month:D2}...");
var installationIds = Db.WeeklyReports // Find all installations that have daily records for the previous month
var installationIds = Db.DailyRecords
.Where(r => String.Compare(r.Date, first.ToString("yyyy-MM-dd"), StringComparison.Ordinal) >= 0
&& String.Compare(r.Date, last.ToString("yyyy-MM-dd"), StringComparison.Ordinal) <= 0)
.Select(r => r.InstallationId) .Select(r => r.InstallationId)
.ToList() .ToList()
.Distinct() .Distinct()
@ -314,11 +333,6 @@ public static class ReportAggregationService
try try
{ {
await AggregateMonthForInstallation(installationId, year, month); await AggregateMonthForInstallation(installationId, year, month);
// Scheduler mode: clean up weekly records after successful aggregation.
// The monthly report is now the source of truth for this month.
Db.DeleteWeeklyReportsForMonth(installationId, year, month);
Console.WriteLine($"[ReportAggregation] Cleaned up weekly records for installation {installationId}, {year}-{month:D2}.");
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -329,23 +343,25 @@ public static class ReportAggregationService
private static async Task AggregateMonthForInstallation(Int64 installationId, Int32 year, Int32 month, String language = "en") private static async Task AggregateMonthForInstallation(Int64 installationId, Int32 year, Int32 month, String language = "en")
{ {
var weeklies = Db.GetWeeklyReportsForMonth(installationId, year, month); // Compute from daily records for the full calendar month
if (weeklies.Count == 0) var first = new DateOnly(year, month, 1);
var last = first.AddMonths(1).AddDays(-1);
var days = Db.GetDailyRecords(installationId, first, last);
if (days.Count == 0)
return; return;
// If monthly report already exists, delete it so we can re-generate with latest weekly data. // If monthly report already exists, delete it so we can re-generate
// This supports partial months and re-aggregation when new weekly data arrives.
Db.DeleteMonthlyReport(installationId, year, month); Db.DeleteMonthlyReport(installationId, year, month);
// Sum energy totals // Sum energy totals directly from daily records
var totalPv = Math.Round(weeklies.Sum(w => w.TotalPvProduction), 1); var totalPv = Math.Round(days.Sum(d => d.PvProduction), 1);
var totalConsump = Math.Round(weeklies.Sum(w => w.TotalConsumption), 1); var totalConsump = Math.Round(days.Sum(d => d.LoadConsumption), 1);
var totalGridIn = Math.Round(weeklies.Sum(w => w.TotalGridImport), 1); var totalGridIn = Math.Round(days.Sum(d => d.GridImport), 1);
var totalGridOut = Math.Round(weeklies.Sum(w => w.TotalGridExport), 1); var totalGridOut = Math.Round(days.Sum(d => d.GridExport), 1);
var totalBattChg = Math.Round(weeklies.Sum(w => w.TotalBatteryCharged), 1); var totalBattChg = Math.Round(days.Sum(d => d.BatteryCharged), 1);
var totalBattDis = Math.Round(weeklies.Sum(w => w.TotalBatteryDischarged), 1); var totalBattDis = Math.Round(days.Sum(d => d.BatteryDischarged), 1);
// Re-derive ratios from aggregated totals // Re-derive ratios
var energySaved = Math.Round(totalConsump - totalGridIn, 1); var energySaved = Math.Round(totalConsump - totalGridIn, 1);
var savingsCHF = Math.Round(energySaved * ElectricityPriceCHF, 0); var savingsCHF = Math.Round(energySaved * ElectricityPriceCHF, 0);
var selfSufficiency = totalConsump > 0 ? Math.Round((totalConsump - totalGridIn) / totalConsump * 100, 1) : 0; var selfSufficiency = totalConsump > 0 ? Math.Round((totalConsump - totalGridIn) / totalConsump * 100, 1) : 0;
@ -353,56 +369,59 @@ public static class ReportAggregationService
var batteryEff = totalBattChg > 0 ? Math.Round(totalBattDis / totalBattChg * 100, 1) : 0; var batteryEff = totalBattChg > 0 ? Math.Round(totalBattDis / totalBattChg * 100, 1) : 0;
var gridDependency = totalConsump > 0 ? Math.Round(totalGridIn / totalConsump * 100, 1) : 0; var gridDependency = totalConsump > 0 ? Math.Round(totalGridIn / totalConsump * 100, 1) : 0;
// Average behavioral highlights
var avgPeakLoad = (Int32)Math.Round(weeklies.Average(w => w.PeakLoadHour));
var avgPeakSolar = (Int32)Math.Round(weeklies.Average(w => w.PeakSolarHour));
var avgWeekdayLoad = Math.Round(weeklies.Average(w => w.WeekdayAvgDailyLoad), 1);
var avgWeekendLoad = Math.Round(weeklies.Average(w => w.WeekendAvgDailyLoad), 1);
// Get installation name for AI insight // Get installation name for AI insight
var installation = Db.GetInstallationById(installationId); var installation = Db.GetInstallationById(installationId);
var installationName = installation?.InstallationName ?? $"Installation {installationId}"; var installationName = installation?.InstallationName ?? $"Installation {installationId}";
var monthName = new DateTime(year, month, 1).ToString("MMMM yyyy"); var monthName = new DateTime(year, month, 1).ToString("MMMM yyyy");
var aiInsight = await GenerateMonthlyAiInsightAsync( var aiInsight = await GenerateMonthlyAiInsightAsync(
installationName, monthName, weeklies.Count, installationName, monthName, days.Count,
totalPv, totalConsump, totalGridIn, totalGridOut, totalPv, totalConsump, totalGridIn, totalGridOut,
totalBattChg, totalBattDis, energySaved, savingsCHF, totalBattChg, totalBattDis, energySaved, savingsCHF,
selfSufficiency, batteryEff, language); selfSufficiency, batteryEff, language);
var monthlySummary = new MonthlyReportSummary var monthlySummary = new MonthlyReportSummary
{ {
InstallationId = installationId, InstallationId = installationId,
Year = year, Year = year,
Month = month, Month = month,
PeriodStart = weeklies.Min(w => w.PeriodStart), PeriodStart = first.ToString("yyyy-MM-dd"),
PeriodEnd = weeklies.Max(w => w.PeriodEnd), PeriodEnd = last.ToString("yyyy-MM-dd"),
TotalPvProduction = totalPv, TotalPvProduction = totalPv,
TotalConsumption = totalConsump, TotalConsumption = totalConsump,
TotalGridImport = totalGridIn, TotalGridImport = totalGridIn,
TotalGridExport = totalGridOut, TotalGridExport = totalGridOut,
TotalBatteryCharged = totalBattChg, TotalBatteryCharged = totalBattChg,
TotalBatteryDischarged = totalBattDis, TotalBatteryDischarged = totalBattDis,
TotalEnergySaved = energySaved, TotalEnergySaved = energySaved,
TotalSavingsCHF = savingsCHF, TotalSavingsCHF = savingsCHF,
SelfSufficiencyPercent = selfSufficiency, SelfSufficiencyPercent = selfSufficiency,
SelfConsumptionPercent = selfConsumption, SelfConsumptionPercent = selfConsumption,
BatteryEfficiencyPercent = batteryEff, BatteryEfficiencyPercent = batteryEff,
GridDependencyPercent = gridDependency, GridDependencyPercent = gridDependency,
AvgPeakLoadHour = avgPeakLoad, AvgPeakLoadHour = 0, // Not available without hourly data; Phase 3 will add
AvgPeakSolarHour = avgPeakSolar, AvgPeakSolarHour = 0,
AvgWeekdayDailyLoad = avgWeekdayLoad, AvgWeekdayDailyLoad = 0,
AvgWeekendDailyLoad = avgWeekendLoad, AvgWeekendDailyLoad = 0,
WeekCount = weeklies.Count, WeekCount = days.Count, // repurposed as day count
AiInsight = aiInsight, AiInsight = aiInsight,
CreatedAt = DateTime.UtcNow.ToString("o"), CreatedAt = DateTime.UtcNow.ToString("o"),
}; };
Db.Create(monthlySummary); Db.Create(monthlySummary);
// Weekly records are kept — allows re-generation if new weekly data arrives.
// Cleanup can be done later by the automated scheduler or manually.
Console.WriteLine($"[ReportAggregation] Monthly report created for installation {installationId}, {year}-{month:D2} ({weeklies.Count} weeks aggregated)."); // Seed AiInsightCache so the generating language is pre-cached
if (!String.IsNullOrEmpty(monthlySummary.AiInsight))
Db.Create(new AiInsightCache
{
ReportType = "monthly",
ReportId = monthlySummary.Id,
Language = language,
InsightText = monthlySummary.AiInsight,
CreatedAt = monthlySummary.CreatedAt,
});
Console.WriteLine($"[ReportAggregation] Monthly report created for installation {installationId}, {year}-{month:D2} ({days.Count} days, {first}{last}).");
} }
// ── Year-End Aggregation ────────────────────────────────────────── // ── Year-End Aggregation ──────────────────────────────────────────
@ -425,17 +444,15 @@ public static class ReportAggregationService
try try
{ {
await AggregateYearForInstallation(installationId, previousYear); await AggregateYearForInstallation(installationId, previousYear);
// Scheduler mode: clean up monthly records after successful aggregation.
// The yearly report is now the source of truth for this year.
Db.DeleteMonthlyReportsForYear(installationId, previousYear);
Console.WriteLine($"[ReportAggregation] Cleaned up monthly records for installation {installationId}, {previousYear}.");
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.Error.WriteLine($"[ReportAggregation] Year aggregation failed for installation {installationId}: {ex.Message}"); Console.Error.WriteLine($"[ReportAggregation] Year aggregation failed for installation {installationId}: {ex.Message}");
} }
} }
// Time-based cleanup: delete records older than 1 year, runs after yearly report is created
CleanupOldRecords();
} }
private static async Task AggregateYearForInstallation(Int64 installationId, Int32 year, String language = "en") private static async Task AggregateYearForInstallation(Int64 installationId, Int32 year, String language = "en")
@ -505,13 +522,140 @@ public static class ReportAggregationService
}; };
Db.Create(yearlySummary); Db.Create(yearlySummary);
// Monthly records are kept — allows re-generation if new monthly data arrives.
// Seed AiInsightCache so the generating language is pre-cached
if (!String.IsNullOrEmpty(yearlySummary.AiInsight))
Db.Create(new AiInsightCache
{
ReportType = "yearly",
ReportId = yearlySummary.Id,
Language = language,
InsightText = yearlySummary.AiInsight,
CreatedAt = yearlySummary.CreatedAt,
});
Console.WriteLine($"[ReportAggregation] Yearly report created for installation {installationId}, {year} ({monthlies.Count} months aggregated)."); Console.WriteLine($"[ReportAggregation] Yearly report created for installation {installationId}, {year} ({monthlies.Count} months aggregated).");
} }
// ── AI Insight Cache ──────────────────────────────────────────────
/// <summary>
/// Returns cached AI insight for (reportType, reportId, language).
/// On cache miss: calls generate(), stores the result, and returns it.
/// This is the single entry-point for all per-language insight reads.
/// </summary>
public static async Task<String> GetOrGenerateInsightAsync(
String reportType, Int64 reportId, String language, Func<Task<String>> generate)
{
var cached = Db.GetCachedInsight(reportType, reportId, language);
if (!String.IsNullOrEmpty(cached))
return cached;
var insight = await generate();
Db.Create(new AiInsightCache
{
ReportType = reportType,
ReportId = reportId,
Language = language,
InsightText = insight,
CreatedAt = DateTime.UtcNow.ToString("o"),
});
return insight;
}
/// <summary>Cached-or-generated AI insight for a stored WeeklyReportSummary.</summary>
public static Task<String> GetOrGenerateWeeklyInsightAsync(
WeeklyReportSummary report, String language)
{
var installationName = Db.GetInstallationById(report.InstallationId)?.InstallationName
?? $"Installation {report.InstallationId}";
return GetOrGenerateInsightAsync("weekly", report.Id, language,
() => GenerateWeeklySummaryAiInsightAsync(report, installationName, language));
}
/// <summary>Cached-or-generated AI insight for a stored MonthlyReportSummary.</summary>
public static Task<String> GetOrGenerateMonthlyInsightAsync(
MonthlyReportSummary report, String language)
{
var installationName = Db.GetInstallationById(report.InstallationId)?.InstallationName
?? $"Installation {report.InstallationId}";
var monthName = new DateTime(report.Year, report.Month, 1).ToString("MMMM yyyy");
return GetOrGenerateInsightAsync("monthly", report.Id, language,
() => GenerateMonthlyAiInsightAsync(
installationName, monthName, report.WeekCount,
report.TotalPvProduction, report.TotalConsumption,
report.TotalGridImport, report.TotalGridExport,
report.TotalBatteryCharged, report.TotalBatteryDischarged,
report.TotalEnergySaved, report.TotalSavingsCHF,
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, language));
}
/// <summary>Cached-or-generated AI insight for a stored YearlyReportSummary.</summary>
public static Task<String> GetOrGenerateYearlyInsightAsync(
YearlyReportSummary report, String language)
{
var installationName = Db.GetInstallationById(report.InstallationId)?.InstallationName
?? $"Installation {report.InstallationId}";
return GetOrGenerateInsightAsync("yearly", report.Id, language,
() => GenerateYearlyAiInsightAsync(
installationName, report.Year, report.MonthCount,
report.TotalPvProduction, report.TotalConsumption,
report.TotalGridImport, report.TotalGridExport,
report.TotalBatteryCharged, report.TotalBatteryDischarged,
report.TotalEnergySaved, report.TotalSavingsCHF,
report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, language));
}
// ── Time-Based Cleanup ────────────────────────────────────────────
/// <summary>
/// Deletes records older than 1 year. Called annually on Jan 2 after
/// yearly reports are created, so monthly summaries are still available
/// when yearly is computed.
/// </summary>
private static void CleanupOldRecords()
{
try
{
Db.CleanupOldData();
}
catch (Exception ex)
{
Console.Error.WriteLine($"[ReportAggregation] Cleanup error: {ex.Message}");
}
}
// ── AI Insight Generation ───────────────────────────────────────── // ── AI Insight Generation ─────────────────────────────────────────
/// <summary>
/// Simplified weekly AI insight generated from the stored WeeklyReportSummary numerical fields.
/// Used for historical weeks where the original hourly data is no longer available.
/// </summary>
private static async Task<String> GenerateWeeklySummaryAiInsightAsync(
WeeklyReportSummary summary, String installationName, String language = "en")
{
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
if (String.IsNullOrWhiteSpace(apiKey))
return "AI insight unavailable (API key not configured).";
var langName = GetLanguageName(language);
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
Write a concise weekly performance summary in {langName} (4 bullet points starting with ""- "").
WEEKLY FACTS for {summary.PeriodStart} to {summary.PeriodEnd}:
- PV production: {summary.TotalPvProduction:F1} kWh | Consumption: {summary.TotalConsumption:F1} kWh
- Grid import: {summary.TotalGridImport:F1} kWh | Grid export: {summary.TotalGridExport:F1} kWh
- Battery: {summary.TotalBatteryCharged:F1} kWh charged, {summary.TotalBatteryDischarged:F1} kWh discharged
- Energy saved: {summary.TotalEnergySaved:F1} kWh = ~{summary.TotalSavingsCHF:F0} CHF
- Self-sufficiency: {summary.SelfSufficiencyPercent:F1}% | Grid dependency: {summary.GridDependencyPercent:F1}%
Rules: Write in {langName}. Write for a homeowner. No asterisks or formatting marks.
Exactly 4 bullet points. Each starts with ""- Title: description"" format.";
return await CallMistralAsync(apiKey, prompt);
}
private static String GetLanguageName(String code) => code switch private static String GetLanguageName(String code) => code switch
{ {
"de" => "German", "de" => "German",
@ -533,26 +677,28 @@ public static class ReportAggregationService
return "AI insight unavailable (API key not configured)."; return "AI insight unavailable (API key not configured).";
var langName = GetLanguageName(language); var langName = GetLanguageName(language);
// Determine which metric is weakest so the tip can be targeted
var weakMetric = batteryEff < 80 ? "battery" : selfSufficiency < 40 ? "self-sufficiency" : "general";
var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"". var prompt = $@"You are an energy advisor for a sodistore home installation: ""{installationName}"".
Write a concise monthly performance summary in {langName} (4 bullet points, plain text, no markdown). Write a concise monthly performance summary in {langName} (4 bullet points, plain text, no markdown).
MONTHLY FACTS for {monthName} ({weekCount} weeks): MONTHLY FACTS for {monthName} ({weekCount} days of data):
- Total PV production: {totalPv:F1} kWh - PV production: {totalPv:F1} kWh
- Total consumption: {totalConsump:F1} kWh - Total consumption: {totalConsump:F1} kWh
- Total grid import: {totalGridIn:F1} kWh, export: {totalGridOut:F1} kWh - Self-sufficiency: {selfSufficiency:F1}% (powered by solar + battery, not grid)
- Battery: {totalBattChg:F1} kWh charged, {totalBattDis:F1} kWh discharged - Battery: {totalBattChg:F1} kWh charged, {totalBattDis:F1} kWh discharged, efficiency {batteryEff:F1}%
- Energy saved from grid: {energySaved:F1} kWh = ~{savingsCHF:F0} CHF (at {ElectricityPriceCHF} CHF/kWh) - Energy saved: {energySaved:F1} kWh = ~{savingsCHF:F0} CHF saved (at {ElectricityPriceCHF} CHF/kWh)
- Self-sufficiency: {selfSufficiency:F1}%
- Battery efficiency: {batteryEff:F1}%
INSTRUCTIONS: INSTRUCTIONS:
1. Monthly savings summary: total energy saved and money saved this month. Use the exact numbers provided. 1. Savings: state exactly how much energy and money was saved this month. Positive framing.
2. Solar & battery performance: comment on PV production and battery utilization. 2. Solar performance: how much the solar system produced and what self-sufficiency % means for the homeowner (e.g. ""X% of your home ran on solar + battery""). Do NOT mention raw grid import kWh.
3. Grid dependency: note grid import relative to total consumption. 3. Battery: comment on battery utilization and efficiency. If efficiency < 80%, note it may need attention.
4. Recommendation for next month: one actionable suggestion. 4. Tip: one specific actionable suggestion based on the weakest metric: {weakMetric}. If general, suggest the most impactful habit change based on the numbers above.
Rules: Write in {langName}. Write for a homeowner. No asterisks or formatting marks. No closing remarks. Exactly 4 bullet points starting with ""- "". Each bullet must have a short title followed by colon then description."; Rules: Write in {langName}. Write for a homeowner, not a technician. No asterisks or formatting marks. No closing remarks. Exactly 4 bullet points starting with ""- "". Each bullet must have a short title followed by colon then description.";
return await CallMistralAsync(apiKey, prompt); return await CallMistralAsync(apiKey, prompt);
} }

View File

@ -217,10 +217,10 @@ public static class ReportEmailService
var cur = r.CurrentWeek; var cur = r.CurrentWeek;
var prev = r.PreviousWeek; var prev = r.PreviousWeek;
// Parse AI insight into <li> bullet points (split on newlines, strip leading "- " or "1. ") // Parse AI insight into <li> bullet points (split on newlines, strip leading "- " or "1. ", strip ** markdown)
var insightLines = r.AiInsight var insightLines = r.AiInsight
.Split('\n', StringSplitOptions.RemoveEmptyEntries) .Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(l => System.Text.RegularExpressions.Regex.Replace(l.Trim(), @"^[\d]+[.)]\s*|^[-*]\s*", "")) .Select(l => System.Text.RegularExpressions.Regex.Replace(l.Trim(), @"^[\d]+[.)]\s*|^[-*]\s*", "").Replace("**", ""))
.Where(l => l.Length > 0) .Where(l => l.Length > 0)
.ToList(); .ToList();
@ -584,7 +584,7 @@ public static class ReportEmailService
{ {
var insightLines = aiInsight var insightLines = aiInsight
.Split('\n', StringSplitOptions.RemoveEmptyEntries) .Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(l => System.Text.RegularExpressions.Regex.Replace(l.Trim(), @"^[\d]+[.)]\s*|^[-*]\s*", "")) .Select(l => System.Text.RegularExpressions.Regex.Replace(l.Trim(), @"^[\d]+[.)]\s*|^[-*]\s*", "").Replace("**", ""))
.Where(l => l.Length > 0) .Where(l => l.Length > 0)
.ToList(); .ToList();

View File

@ -1,4 +1,5 @@
using Flurl.Http; using Flurl.Http;
using InnovEnergy.App.Backend.Database;
using InnovEnergy.App.Backend.DataTypes; using InnovEnergy.App.Backend.DataTypes;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -8,22 +9,73 @@ public static class WeeklyReportService
{ {
private static readonly string TmpReportDir = Environment.CurrentDirectory + "/tmp_report/"; private static readonly string TmpReportDir = Environment.CurrentDirectory + "/tmp_report/";
// ── 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> /// <summary>
/// Generates a full weekly report for the given installation. /// Generates a full weekly report for the given installation.
/// Cache is invalidated automatically when the xlsx file is newer than the cache. /// Data source priority:
/// To force regeneration (e.g. after a prompt change), simply delete the cache files. /// 1. DailyEnergyRecord rows in SQLite (populated by DailyIngestionService)
/// 2. xlsx file fallback (if DB not yet populated for the target week)
/// 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> /// </summary>
public static async Task<WeeklyReportResponse> GenerateReportAsync(long installationId, string installationName, string language = "en") public static async Task<WeeklyReportResponse> GenerateReportAsync(
long installationId, string installationName, string language = "en",
DateOnly? weekStartOverride = null)
{ {
var xlsxPath = TmpReportDir + installationId + ".xlsx"; DateOnly curMon, curSun, prevMon, prevSun;
var cachePath = TmpReportDir + $"{installationId}_{language}.cache.json";
// Use cached report if xlsx hasn't changed since cache was written if (weekStartOverride.HasValue)
if (File.Exists(cachePath) && File.Exists(xlsxPath))
{ {
var xlsxModified = File.GetLastWriteTimeUtc(xlsxPath); // Debug/backfill mode: use the provided Monday as the week start
var cacheModified = File.GetLastWriteTimeUtc(cachePath); curMon = weekStartOverride.Value;
if (cacheModified > xlsxModified) curSun = curMon.AddDays(6);
prevMon = curMon.AddDays(-7);
prevSun = curMon.AddDays(-1);
}
else
{
(curMon, curSun) = LastCalendarWeek();
(prevMon, prevSun) = PreviousCalendarWeek();
}
// Skip file cache when a specific week is requested (avoid stale or wrong-week hits)
var cachePath = weekStartOverride.HasValue
? null
: TmpReportDir + $"{installationId}_{language}_{curMon:yyyy-MM-dd}.cache.json";
// Use cache if it exists and is less than 6 hours old (skipped in override mode)
if (cachePath != null && File.Exists(cachePath))
{
var cacheAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(cachePath);
if (cacheAge.TotalHours < 6)
{ {
try try
{ {
@ -31,7 +83,7 @@ public static class WeeklyReportService
await File.ReadAllTextAsync(cachePath)); await File.ReadAllTextAsync(cachePath));
if (cached != null) if (cached != null)
{ {
Console.WriteLine($"[WeeklyReportService] Returning cached report for installation {installationId} ({language})."); Console.WriteLine($"[WeeklyReportService] Returning cached report for installation {installationId} ({language}), week {curMon:yyyy-MM-dd}.");
return cached; return cached;
} }
} }
@ -42,54 +94,94 @@ public static class WeeklyReportService
} }
} }
// Parse both daily summaries and hourly intervals from the same xlsx // 1. Try to load daily records from SQLite for the calendar weeks
var allDays = ExcelDataParser.Parse(xlsxPath); var currentWeekDays = Db.GetDailyRecords(installationId, curMon, curSun)
var allHourly = ExcelDataParser.ParseHourly(xlsxPath); .Select(r => ToDailyEnergyData(r)).ToList();
var report = await GenerateReportFromDataAsync(allDays, allHourly, installationName, language); var previousWeekDays = Db.GetDailyRecords(installationId, prevMon, prevSun)
.Select(r => ToDailyEnergyData(r)).ToList();
// Write cache // 2. Fallback: if DB empty for current week, parse all xlsx files on the fly
try var xlsxFiles = Directory.Exists(TmpReportDir)
? Directory.GetFiles(TmpReportDir, $"{installationId}*.xlsx").OrderBy(f => f).ToList()
: new List<String>();
if (currentWeekDays.Count == 0 && xlsxFiles.Count > 0)
{ {
await File.WriteAllTextAsync(cachePath, JsonConvert.SerializeObject(report)); Console.WriteLine($"[WeeklyReportService] DB empty for week {curMon:yyyy-MM-dd}, falling back to xlsx.");
var allDaysParsed = xlsxFiles.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();
} }
catch (Exception ex)
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.");
// 3. Load hourly data from ALL xlsx files for behavioral analysis (current week only).
// Combine all files so any week can find its hourly data regardless of file split.
// Future: replace with S3 hourly fetch.
var allHourly = xlsxFiles
.SelectMany(p => { try { return ExcelDataParser.ParseHourly(p); } catch { return Enumerable.Empty<HourlyEnergyData>(); } })
.ToList();
var curMonDt = curMon.ToDateTime(TimeOnly.MinValue);
var curSunDt = curSun.ToDateTime(TimeOnly.MaxValue);
var currentHourlyData = allHourly
.Where(h => h.DateTime >= curMonDt && h.DateTime <= curSunDt)
.ToList();
var report = await GenerateReportFromDataAsync(
currentWeekDays, previousWeekDays, currentHourlyData, installationName, language,
curMon, curSun);
// Write cache (skipped in override mode)
if (cachePath != null)
{ {
Console.Error.WriteLine($"[WeeklyReportService] Could not write cache: {ex.Message}"); try
{
await File.WriteAllTextAsync(cachePath, JsonConvert.SerializeObject(report));
}
catch (Exception ex)
{
Console.Error.WriteLine($"[WeeklyReportService] Could not write cache: {ex.Message}");
}
} }
return report; return report;
} }
// ── 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,
};
/// <summary> /// <summary>
/// Core report generation. Accepts both daily summaries and hourly intervals. /// 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> /// </summary>
public static async Task<WeeklyReportResponse> GenerateReportFromDataAsync( public static async Task<WeeklyReportResponse> GenerateReportFromDataAsync(
List<DailyEnergyData> allDays, List<DailyEnergyData> currentWeekDays,
List<HourlyEnergyData> allHourly, List<DailyEnergyData> previousWeekDays,
List<HourlyEnergyData> currentHourlyData,
string installationName, string installationName,
string language = "en") string language = "en",
DateOnly? weekStart = null,
DateOnly? weekEnd = null)
{ {
// Sort by date currentWeekDays = currentWeekDays.OrderBy(d => d.Date).ToList();
allDays = allDays.OrderBy(d => d.Date).ToList(); previousWeekDays = previousWeekDays.OrderBy(d => d.Date).ToList();
// Split into previous week and current week (daily)
List<DailyEnergyData> previousWeekDays;
List<DailyEnergyData> currentWeekDays;
if (allDays.Count > 7)
{
previousWeekDays = allDays.Take(allDays.Count - 7).ToList();
currentWeekDays = allDays.Skip(allDays.Count - 7).ToList();
}
else
{
previousWeekDays = new List<DailyEnergyData>();
currentWeekDays = allDays;
}
// Restrict hourly data to current week only for behavioral analysis
var currentWeekStart = DateTime.Parse(currentWeekDays.First().Date);
var currentHourlyData = allHourly.Where(h => h.DateTime.Date >= currentWeekStart.Date).ToList();
var currentSummary = Summarize(currentWeekDays); var currentSummary = Summarize(currentWeekDays);
var previousSummary = previousWeekDays.Count > 0 ? Summarize(previousWeekDays) : null; var previousSummary = previousWeekDays.Count > 0 ? Summarize(previousWeekDays) : null;
@ -149,7 +241,7 @@ public static class WeeklyReportService
PvChangePercent = pvChange, PvChangePercent = pvChange,
ConsumptionChangePercent = consumptionChange, ConsumptionChangePercent = consumptionChange,
GridImportChangePercent = gridImportChange, GridImportChangePercent = gridImportChange,
DailyData = allDays, DailyData = currentWeekDays,
Behavior = behavior, Behavior = behavior,
AiInsight = aiInsight, AiInsight = aiInsight,
}; };

View File

@ -224,10 +224,11 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
const [generating, setGenerating] = useState<string | null>(null); const [generating, setGenerating] = useState<string | null>(null);
const fetchReportData = () => { const fetchReportData = () => {
axiosConfig.get('/GetMonthlyReports', { params: { installationId } }) const lang = intl.locale;
axiosConfig.get('/GetMonthlyReports', { params: { installationId, language: lang } })
.then(res => setMonthlyReports(res.data)) .then(res => setMonthlyReports(res.data))
.catch(() => {}); .catch(() => {});
axiosConfig.get('/GetYearlyReports', { params: { installationId } }) axiosConfig.get('/GetYearlyReports', { params: { installationId, language: lang } })
.then(res => setYearlyReports(res.data)) .then(res => setYearlyReports(res.data))
.catch(() => {}); .catch(() => {});
axiosConfig.get('/GetPendingMonthlyAggregations', { params: { installationId } }) axiosConfig.get('/GetPendingMonthlyAggregations', { params: { installationId } })
@ -238,7 +239,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
.catch(() => {}); .catch(() => {});
}; };
useEffect(() => { fetchReportData(); }, [installationId]); useEffect(() => { fetchReportData(); }, [installationId, intl.locale]);
const handleGenerateMonthly = async (year: number, month: number) => { const handleGenerateMonthly = async (year: number, month: number) => {
setGenerating(`monthly-${year}-${month}`); setGenerating(`monthly-${year}-${month}`);
@ -277,7 +278,7 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
const safeTab = Math.min(activeTab, tabs.length - 1); const safeTab = Math.min(activeTab, tabs.length - 1);
return ( return (
<Box sx={{ p: 2 }}> <Box sx={{ p: 2, maxWidth: 900, mx: 'auto' }}>
<Tabs <Tabs
value={safeTab} value={safeTab}
onChange={(_, v) => setActiveTab(v)} onChange={(_, v) => setActiveTab(v)}
@ -286,10 +287,17 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
{tabs.map(t => <Tab key={t.key} label={t.label} />)} {tabs.map(t => <Tab key={t.key} label={t.label} />)}
</Tabs> </Tabs>
{tabs[safeTab]?.key === 'weekly' && ( <Box sx={{ display: tabs[safeTab]?.key === 'weekly' ? 'block' : 'none' }}>
<WeeklySection installationId={installationId} /> <WeeklySection
)} installationId={installationId}
{tabs[safeTab]?.key === 'monthly' && ( latestMonthlyPeriodEnd={
monthlyReports.length > 0
? monthlyReports.reduce((a, b) => a.periodEnd > b.periodEnd ? a : b).periodEnd
: null
}
/>
</Box>
<Box sx={{ display: tabs[safeTab]?.key === 'monthly' ? 'block' : 'none' }}>
<MonthlySection <MonthlySection
installationId={installationId} installationId={installationId}
reports={monthlyReports} reports={monthlyReports}
@ -297,8 +305,8 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
generating={generating} generating={generating}
onGenerate={handleGenerateMonthly} onGenerate={handleGenerateMonthly}
/> />
)} </Box>
{tabs[safeTab]?.key === 'yearly' && ( <Box sx={{ display: tabs[safeTab]?.key === 'yearly' ? 'block' : 'none' }}>
<YearlySection <YearlySection
installationId={installationId} installationId={installationId}
reports={yearlyReports} reports={yearlyReports}
@ -306,14 +314,14 @@ function WeeklyReport({ installationId }: WeeklyReportProps) {
generating={generating} generating={generating}
onGenerate={handleGenerateYearly} onGenerate={handleGenerateYearly}
/> />
)} </Box>
</Box> </Box>
); );
} }
// ── Weekly Section (existing weekly report content) ──────────── // ── Weekly Section (existing weekly report content) ────────────
function WeeklySection({ installationId }: { installationId: number }) { function WeeklySection({ installationId, latestMonthlyPeriodEnd }: { installationId: number; latestMonthlyPeriodEnd: string | null }) {
const intl = useIntl(); const intl = useIntl();
const [report, setReport] = useState<WeeklyReportResponse | null>(null); const [report, setReport] = useState<WeeklyReportResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -391,9 +399,13 @@ function WeeklySection({ installationId }: { installationId: number }) {
return effective > 0 ? '#27ae60' : effective < 0 ? '#e74c3c' : '#888'; return effective > 0 ? '#27ae60' : effective < 0 ? '#e74c3c' : '#888';
}; };
// Compute change % from two raw values; returns 0 (shown as —) if prev is 0
const calcChange = (curVal: number, prevVal: number) =>
prevVal === 0 ? 0 : ((curVal - prevVal) / prevVal) * 100;
const insightBullets = report.aiInsight const insightBullets = report.aiInsight
.split(/\n+/) .split(/\n+/)
.map((line) => line.replace(/^[\d]+[.)]\s*/, '').replace(/^[-*]\s*/, '').trim()) .map((line) => line.replace(/\*\*/g, '').replace(/^[\d]+[.)]\s*/, '').replace(/^[-*]\s*/, '').trim())
.filter((line) => line.length > 0); .filter((line) => line.length > 0);
const totalEnergySavedKwh = report.totalEnergySaved; const totalEnergySavedKwh = report.totalEnergySaved;
@ -502,13 +514,13 @@ function WeeklySection({ installationId }: { installationId: number }) {
<td><FormattedMessage id="gridExport" defaultMessage="Grid Export" /></td> <td><FormattedMessage id="gridExport" defaultMessage="Grid Export" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{cur.totalGridExport.toFixed(1)} kWh</td> <td style={{ textAlign: 'right', fontWeight: 'bold' }}>{cur.totalGridExport.toFixed(1)} kWh</td>
{prev && <td style={{ textAlign: 'right', color: '#888' }}>{prev.totalGridExport.toFixed(1)} kWh</td>} {prev && <td style={{ textAlign: 'right', color: '#888' }}>{prev.totalGridExport.toFixed(1)} kWh</td>}
{prev && <td style={{ textAlign: 'right' }}></td>} {prev && <td style={{ textAlign: 'right', color: '#888' }}>{formatChange(calcChange(cur.totalGridExport, prev.totalGridExport))}</td>}
</tr> </tr>
<tr> <tr>
<td><FormattedMessage id="batteryInOut" defaultMessage="Battery In / Out" /></td> <td><FormattedMessage id="batteryInOut" defaultMessage="Battery In / Out" /></td>
<td style={{ textAlign: 'right', fontWeight: 'bold' }}>{cur.totalBatteryCharged.toFixed(1)} / {cur.totalBatteryDischarged.toFixed(1)} kWh</td> <td style={{ textAlign: 'right', fontWeight: 'bold' }}>{cur.totalBatteryCharged.toFixed(1)} / {cur.totalBatteryDischarged.toFixed(1)} kWh</td>
{prev && <td style={{ textAlign: 'right', color: '#888' }}>{prev.totalBatteryCharged.toFixed(1)} / {prev.totalBatteryDischarged.toFixed(1)} kWh</td>} {prev && <td style={{ textAlign: 'right', color: '#888' }}>{prev.totalBatteryCharged.toFixed(1)} / {prev.totalBatteryDischarged.toFixed(1)} kWh</td>}
{prev && <td style={{ textAlign: 'right' }}></td>} {prev && <td style={{ textAlign: 'right', color: changeColor(calcChange(cur.totalBatteryCharged, prev.totalBatteryCharged)) }}>{formatChange(calcChange(cur.totalBatteryCharged, prev.totalBatteryCharged))}</td>}
</tr> </tr>
</tbody> </tbody>
</Box> </Box>
@ -557,33 +569,69 @@ function WeeklySection({ installationId }: { installationId: number }) {
</Paper> </Paper>
)} )}
{/* Weekly History for current month */} {/* Weekly History — weeks not yet captured in a monthly report, excluding the current week shown above */}
<WeeklyHistory installationId={installationId} /> <WeeklyHistory
installationId={installationId}
latestMonthlyPeriodEnd={latestMonthlyPeriodEnd}
currentReportPeriodEnd={report?.periodEnd ?? null}
/>
</> </>
); );
} }
// ── Weekly History (saved weekly reports for current month) ───── // ── Weekly History (saved weekly reports for current month) ─────
function WeeklyHistory({ installationId }: { installationId: number }) { function WeeklyHistory({ installationId, latestMonthlyPeriodEnd, currentReportPeriodEnd }: {
installationId: number;
latestMonthlyPeriodEnd: string | null;
currentReportPeriodEnd: string | null;
}) {
const intl = useIntl(); const intl = useIntl();
const [records, setRecords] = useState<WeeklyReportSummaryRecord[]>([]); const [records, setRecords] = useState<WeeklyReportSummaryRecord[]>([]);
useEffect(() => { useEffect(() => {
const now = new Date(); const now = new Date();
axiosConfig.get('/GetWeeklyReportSummaries', { const year = now.getFullYear();
params: { installationId, year: now.getFullYear(), month: now.getMonth() + 1 } const month = now.getMonth() + 1;
}) const lang = intl.locale;
.then(res => setRecords(res.data))
.catch(() => {}); // Fetch current month and previous month to cover cross-month weeks
}, [installationId]); const prevMonth = month === 1 ? 12 : month - 1;
const prevYear = month === 1 ? year - 1 : year;
Promise.all([
axiosConfig.get('/GetWeeklyReportSummaries', { params: { installationId, year, month, language: lang } })
.then(r => r.data as WeeklyReportSummaryRecord[]).catch(() => [] as WeeklyReportSummaryRecord[]),
axiosConfig.get('/GetWeeklyReportSummaries', { params: { installationId, year: prevYear, month: prevMonth, language: lang } })
.then(r => r.data as WeeklyReportSummaryRecord[]).catch(() => [] as WeeklyReportSummaryRecord[])
]).then(([cur, prev]) => {
// Combine, deduplicate by id, sort descending by periodEnd
const all = [...cur, ...prev];
const unique = Array.from(new Map(all.map(r => [r.id, r])).values())
.sort((a, b) => b.periodEnd.localeCompare(a.periodEnd));
// Exclude the current week already shown as the main report above
const withoutCurrent = currentReportPeriodEnd
? unique.filter(r => r.periodEnd !== currentReportPeriodEnd)
: unique;
// Keep only weeks not yet covered by a monthly report
const uncovered = latestMonthlyPeriodEnd
? withoutCurrent.filter(r => r.periodEnd > latestMonthlyPeriodEnd)
: withoutCurrent;
// Always show at least the latest non-current week even if all are covered
const toShow = uncovered.length > 0 ? uncovered : withoutCurrent.slice(0, 1);
setRecords(toShow);
});
}, [installationId, latestMonthlyPeriodEnd, currentReportPeriodEnd, intl.locale]);
if (records.length === 0) return null; if (records.length === 0) return null;
return ( return (
<Paper sx={{ p: 3, mb: 3 }}> <Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}> <Typography variant="h6" fontWeight="bold" sx={{ mb: 2, color: '#2c3e50' }}>
<FormattedMessage id="thisMonthWeeklyReports" defaultMessage="This Month's Weekly Reports" /> <FormattedMessage id="recentWeeklyReports" defaultMessage="Recent Weekly Reports" />
</Typography> </Typography>
{records.map((rec) => ( {records.map((rec) => (
<Accordion key={rec.id} sx={{ mb: 1, '&:before': { display: 'none' } }} disableGutters> <Accordion key={rec.id} sx={{ mb: 1, '&:before': { display: 'none' } }} disableGutters>
@ -639,15 +687,6 @@ function WeeklyHistory({ installationId }: { installationId: number }) {
</tr> </tr>
</tbody> </tbody>
</Box> </Box>
{rec.aiInsight && rec.aiInsight.length > 10 && (
<Box sx={{ mt: 1.5, bgcolor: '#fef9e7', borderLeft: '3px solid #f39c12', p: 1.5, borderRadius: '0 6px 6px 0', fontSize: '12px', color: '#555' }}>
{rec.aiInsight.split(/\n+/).filter(l => l.trim()).slice(0, 2).map((line, i) => (
<Typography key={i} variant="caption" display="block" sx={{ lineHeight: 1.5 }}>
{line.replace(/^[-*]\s*/, '').replace(/^[\d]+[.)]\s*/, '')}
</Typography>
))}
</Box>
)}
</AccordionDetails> </AccordionDetails>
</Accordion> </Accordion>
))} ))}
@ -842,7 +881,7 @@ function AggregatedSection<T extends ReportSummary>({
const insightBullets = r.aiInsight const insightBullets = r.aiInsight
.split(/\n+/) .split(/\n+/)
.map((line) => line.replace(/^[\d]+[.)]\s*/, '').replace(/^[-*]\s*/, '').trim()) .map((line) => line.replace(/\*\*/g, '').replace(/^[\d]+[.)]\s*/, '').replace(/^[-*]\s*/, '').trim())
.filter((line) => line.length > 0); .filter((line) => line.length > 0);
const handleSendEmail = async (emailAddress: string) => { const handleSendEmail = async (emailAddress: string) => {