diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ffdfbe3..8a0ce8f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -127,6 +127,7 @@ jobs: ASPNETCORE_ENVIRONMENT=Production ASPNETCORE_URLS=http://0.0.0.0:5000 ConnectionStrings__DefaultConnection="Server=${{ secrets.RDS_HOST }};Port=3306;Database=${{ secrets.RDS_DB_NAME }};Uid=${{ secrets.RDS_DB_USER }};Pwd=${{ secrets.RDS_DB_PASSWORD }};SslMode=Required;" + GOOGLE_API_KEY=${{ secrets.GOOGLE_API_KEY }} EOF sudo chown ${{ secrets.LIGHTSAIL_USER }}:${{ secrets.LIGHTSAIL_USER }} /var/www/culinarycommand/.env sudo chmod 640 /var/www/culinarycommand/.env @@ -135,6 +136,7 @@ jobs: ASPNETCORE_ENVIRONMENT=Production ASPNETCORE_URLS=http://0.0.0.0:5000 export ConnectionStrings__DefaultConnection="Server=${{ secrets.RDS_HOST }};Port=3306;Database=${{ secrets.RDS_DB_NAME }};Uid=${{ secrets.RDS_DB_USER }};Pwd=${{ secrets.RDS_DB_PASSWORD }};SslMode=Required;" + export GOOGLE_API_KEY="${{ secrets.GOOGLE_API_KEY }}" EOF sudo chown ${{ secrets.LIGHTSAIL_USER }}:${{ secrets.LIGHTSAIL_USER }} /var/www/culinarycommand/.env.export sudo chmod 640 /var/www/culinarycommand/.env.export diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 58d2c5a..96b6371 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -42,5 +42,4 @@ jobs: - name: Run Culinary Command xUnit tests run: | - dotnet test CulinaryCommandApp/CulinaryCommand.sln - + dotnet test CulinaryCommandApp/CulinaryCommand.sln \ No newline at end of file diff --git a/CulinaryCommandApp/AIDashboard/DTOs/AIAnalysisResultDTO.cs b/CulinaryCommandApp/AIDashboard/DTOs/AIAnalysisResultDTO.cs new file mode 100644 index 0000000..a6c1e40 --- /dev/null +++ b/CulinaryCommandApp/AIDashboard/DTOs/AIAnalysisResultDTO.cs @@ -0,0 +1,63 @@ +namespace CulinaryCommandApp.AIDashboard.Services.DTOs +{ + using System; + using System.Collections.Generic; + using System.Text.Json.Serialization; + + public class AIAnalysisResultDTO + { + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("summary")] + public string? Summary { get; set; } + + [JsonPropertyName("metrics")] + public List? Metrics { get; set; } + + [JsonPropertyName("sections")] + public List
? Sections { get; set; } + + [JsonPropertyName("anomalies")] + public List? Anomalies { get; set; } + + [JsonPropertyName("recommendations")] + public List? Recommendations { get; set; } + + [JsonPropertyName("generatedAt")] + public DateTimeOffset? GeneratedAt { get; set; } + + [JsonPropertyName("confidence")] + public double? Confidence { get; set; } + } + + public class Metric + { + [JsonPropertyName("label")] + public string? Label { get; set; } + + [JsonPropertyName("value")] + public string? Value { get; set; } + + [JsonPropertyName("unit")] + public string? Unit { get; set; } + } + public class Section + { + [JsonPropertyName("heading")] + public string? Heading { get; set; } + + [JsonPropertyName("body")] + public string? Body { get; set; } + } + + public class Anomaly + { + [JsonPropertyName("row")] + public string? Row { get; set; } + + [JsonPropertyName("reason")] + public string? Reason { get; set; } + + } +} \ No newline at end of file diff --git a/CulinaryCommandApp/AIDashboard/Services/Reporting/AIReportingService.cs b/CulinaryCommandApp/AIDashboard/Services/Reporting/AIReportingService.cs new file mode 100644 index 0000000..500cf5a --- /dev/null +++ b/CulinaryCommandApp/AIDashboard/Services/Reporting/AIReportingService.cs @@ -0,0 +1,124 @@ +namespace CulinaryCommandApp.AIDashboard.Services.Reporting +{ + using Google.GenAI; + using Google.GenAI.Types; + using System; + using System.Threading.Tasks; + using System.Linq; + using System.Text; + using System.Globalization; + using System.Text.Json.Serialization; + using System.Text.Json; + using CulinaryCommandApp.AIDashboard.Services.DTOs; + + + public class AIReportingService + { + private readonly Client _client; + + public AIReportingService(Client client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + + public async Task AnalyzeCsvAsync(string? csvPath = null) + { + const string PayloadSchema = @" You are an expert restaurant data analyst. + Analyze the CSV I give you and return ONLY a JSON object + that exactly follows this SCHEMA: + { + ""title"": string, + ""summary"": string, + ""metrics"": [ { ""label"": string, ""value"": string, ""unit"": string|null } ], + ""sections"": [ { ""heading"": string, ""body"": string } ], + ""anomalies"": [ { ""row"": string, ""reason"": string } ], + ""recommendations"": [ string ], + ""generatedAt"": ""ISO-8601 datetime string"", + ""confidence"": ""number"" (0.0 to 1.0) + } + Constraints: + - Return only JSON (no surrounding text, no markdown fences). + - For long text blocks, try to be concise in the ""body"" fields. + - Use ISO-8601 for generatedAt. + - Provide a numeric confidence between 0 and 1. + + Analysis requirements: + - Use business/user friendly tone. + - If there are anomalies, prompt the user to investigate. + - Include top seller focus analysis. + - Provide quick action items if appropriate. + Now analyze the CSV content below and return the JSON. + "; + + + if (string.IsNullOrWhiteSpace(csvPath)) + { + Console.WriteLine("CSV path not provided."); + return "CSV path not provided."; + } + + if (!System.IO.File.Exists(csvPath)) + { + Console.WriteLine($"CSV not found at: {csvPath}"); + return $"CSV not found at: {csvPath}"; + } + + var lines = System.IO.File.ReadAllLines(csvPath).ToList(); + + if (lines.Count <= 1) + { + Console.WriteLine("CSV empty or only header."); + return "CSV empty or only header."; + } + + var header = lines[0]; + var rows = lines.Skip(1).ToList(); + + /**** Build payload to send to Gemini model****/ + var geminiPayload = new StringBuilder(); + + geminiPayload.AppendLine(PayloadSchema); + geminiPayload.AppendLine(header); + foreach (var r in rows) geminiPayload.AppendLine(r); + + Console.WriteLine(geminiPayload); + + /***** Make API call to Gemini with payload ******/ + var response = await _client.Models.GenerateContentAsync( + model: "gemini-3-flash-preview", + contents: geminiPayload.ToString() + ); + + Console.WriteLine(response.Candidates[0].Content.Parts[0].Text); + + var AIAnalysisResponse = response?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text; + + /*** Deserialize ***/ + + if (!string.IsNullOrWhiteSpace(AIAnalysisResponse)) + { + try + { + var options = new System.Text.Json.JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + }; + + var result = System.Text.Json.JsonSerializer.Deserialize(AIAnalysisResponse, options); + + if (result != null) + { + return System.Text.Json.JsonSerializer.Serialize(result, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + } + } + catch (JsonException) + { + // just catch the exception and move on + } + } + + return AIAnalysisResponse ?? "(no analysis returned)"; + } + + } +} \ No newline at end of file diff --git a/CulinaryCommandApp/AIDashboard/Services/Reporting/test_data.csv b/CulinaryCommandApp/AIDashboard/Services/Reporting/test_data.csv new file mode 100644 index 0000000..464811f --- /dev/null +++ b/CulinaryCommandApp/AIDashboard/Services/Reporting/test_data.csv @@ -0,0 +1,101 @@ +date,dish_ordered,dish_price +2025-08-09,Beef Tacos,9.75 +2025-08-21,Beef Tacos,9.75 +2025-12-26,Grilled Chicken Salad,10.49 +2025-01-12,Spaghetti Bolognese,12.99 +2025-09-08,Beef Tacos,9.75 +2025-01-25,Grilled Chicken Salad,10.49 +2025-02-14,Fish and Chips,11.99 +2025-05-29,Sushi Platter,15.99 +2025-11-26,Fish and Chips,11.99 +2025-05-26,Fish and Chips,11.99 +2025-02-27,Chicken Alfredo,13.5 +2025-09-29,Spaghetti Bolognese,12.99 +2025-08-05,Sushi Platter,15.99 +2025-12-11,Spaghetti Bolognese,12.99 +2025-04-22,Beef Tacos,9.75 +2025-04-28,Fish and Chips,11.99 +2025-08-31,Spaghetti Bolognese,12.99 +2025-11-02,Grilled Chicken Salad,10.49 +2025-09-18,Margherita Pizza,11.25 +2025-12-24,Margherita Pizza,11.25 +2025-11-09,Margherita Pizza,11.25 +2025-03-10,Sushi Platter,15.99 +2025-12-31,Margherita Pizza,11.25 +2025-06-27,Chicken Alfredo,13.5 +2025-02-28,Chicken Alfredo,13.5 +2025-09-16,Chicken Alfredo,13.5 +2025-12-28,Beef Tacos,9.75 +2025-11-27,Chicken Alfredo,13.5 +2025-07-18,Spaghetti Bolognese,12.99 +2025-05-29,Chicken Alfredo,13.5 +2025-11-03,Veggie Burger,8.95 +2025-05-09,Fish and Chips,11.99 +2025-05-20,Spaghetti Bolognese,12.99 +2025-04-23,Sushi Platter,15.99 +2025-07-08,Sushi Platter,15.99 +2025-08-06,Spaghetti Bolognese,12.99 +2025-07-12,Grilled Chicken Salad,10.49 +2025-04-13,Grilled Chicken Salad,10.49 +2025-04-22,Fish and Chips,11.99 +2025-03-09,Beef Tacos,9.75 +2025-06-17,Spaghetti Bolognese,12.99 +2025-02-26,Spaghetti Bolognese,12.99 +2025-06-19,Fish and Chips,11.99 +2025-06-16,Beef Tacos,9.75 +2025-07-10,Spaghetti Bolognese,12.99 +2025-02-03,Spaghetti Bolognese,12.99 +2025-07-22,Chicken Alfredo,13.5 +2025-03-17,Grilled Chicken Salad,10.49 +2025-07-24,Fish and Chips,11.99 +2025-01-29,Veggie Burger,8.95 +2025-08-21,Veggie Burger,8.95 +2025-12-30,Fish and Chips,11.99 +2025-09-04,Fish and Chips,11.99 +2025-05-04,Spaghetti Bolognese,12.99 +2025-11-06,Sushi Platter,15.99 +2035-05-15,Spaghetti Bolognese,999.99 +2025-12-12,Chicken Alfredo,13.5 +2025-11-15,Sushi Platter,15.99 +2025-05-19,Chicken Alfredo,13.5 +2025-04-01,Grilled Chicken Salad,10.49 +2025-01-03,Beef Tacos,9.75 +2025-08-22,Spaghetti Bolognese,12.99 +2025-12-29,Grilled Chicken Salad,10.49 +2025-03-22,Margherita Pizza,11.25 +2025-04-29,Veggie Burger,8.95 +2025-05-31,Grilled Chicken Salad,10.49 +2025-02-07,Beef Tacos,9.75 +2025-09-11,Veggie Burger,8.95 +2025-12-22,Veggie Burger,8.95 +2025-05-29,Margherita Pizza,11.25 +2025-01-19,Beef Tacos,9.75 +2025-07-12,Beef Tacos,9.75 +2025-02-09,Sushi Platter,15.99 +2025-10-05,Beef Tacos,9.75 +2025-06-06,Beef Tacos,9.75 +2025-08-29,Margherita Pizza,11.25 +2025-09-08,Chicken Alfredo,13.5 +2025-11-06,Beef Tacos,9.75 +2025-03-27,Margherita Pizza,11.25 +2025-03-08,Veggie Burger,8.95 +2025-07-18,Margherita Pizza,11.25 +2025-08-29,Beef Tacos,9.75 +2025-03-02,Grilled Chicken Salad,10.49 +2025-03-06,Margherita Pizza,11.25 +2025-11-28,Beef Tacos,9.75 +2025-11-15,Spaghetti Bolognese,12.99 +2025-11-12,Grilled Chicken Salad,10.49 +2025-08-05,Veggie Burger,8.95 +2025-07-31,Sushi Platter,15.99 +2025-01-14,Margherita Pizza,11.25 +2025-04-01,Sushi Platter,15.99 +2025-11-09,Spaghetti Bolognese,12.99 +2025-12-23,Veggie Burger,8.95 +2025-12-22,Veggie Burger,8.95 +2025-05-25,Chicken Alfredo,13.5 +2025-11-04,Grilled Chicken Salad,10.49 +2025-04-13,Sushi Platter,15.99 +2025-09-27,Fish and Chips,11.99 +2025-09-29,Chicken Alfredo,13.5 +2025-09-01,Chicken Alfredo,13.5 diff --git a/CulinaryCommandApp/Components/Pages/Dashboard.razor b/CulinaryCommandApp/Components/Pages/Dashboard.razor index 0ab55e0..9ec92df 100644 --- a/CulinaryCommandApp/Components/Pages/Dashboard.razor +++ b/CulinaryCommandApp/Components/Pages/Dashboard.razor @@ -2,9 +2,12 @@ @rendermode InteractiveServer @using CulinaryCommand.Components.Custom - +@using CulinaryCommandApp.AIDashboard.Services.Reporting +@using CulinaryCommandApp.AIDashboard.Services.DTOs +@inject Microsoft.AspNetCore.Hosting.IWebHostEnvironment Env @inject CulinaryCommand.Services.AuthService Auth @inject NavigationManager Nav +@inject AIReportingService ReportingService @if (!_ready) @@ -32,11 +35,117 @@ else { } + +
+ +
+
+
+ Weekly Report Analysis +
+ + @if (_aiLoading) + { +
Loading analysis from Gemini...
+ } + else if (_aiAnalysisObj == null) + { +
No analysis available.
+ } + else + { +
+
+
+
+
@_aiAnalysisObj.Title
+

@_aiAnalysisObj.Summary

+

Generated: @_aiAnalysisObj.GeneratedAt?.ToLocalTime().ToString("g")

+

Confidence: @((_aiAnalysisObj.Confidence ?? 0).ToString("P0"))

+
+
+
+ +
+
+ @if (_aiAnalysisObj.Metrics != null && _aiAnalysisObj.Metrics.Any()) + { + foreach (var m in _aiAnalysisObj.Metrics) + { +
+
+
+
@m.Label
+

@m.Value @(!string.IsNullOrWhiteSpace(m.Unit) ? m.Unit : "")

+
+
+
+ } + } +
+ +
+ @if (_aiAnalysisObj.Recommendations != null && _aiAnalysisObj.Recommendations.Any()) + { +
Recommendations
+
    + @foreach (var r in _aiAnalysisObj.Recommendations) + { +
  • @r
  • + } +
+ } +
+
+
+ + @if (_aiAnalysisObj.Sections != null && _aiAnalysisObj.Sections.Any()) + { +
+ @foreach (var s in _aiAnalysisObj.Sections) + { +
+
+
+
@s.Heading
+

@s.Body

+
+
+
+ } +
+ } + + @if (_aiAnalysisObj.Anomalies != null && _aiAnalysisObj.Anomalies.Any()) + { +
+
Anomalies
+
+ + + + @foreach (var a in _aiAnalysisObj.Anomalies) + { + + } + +
RowReason
@a.Row@a.Reason
+
+
+ } + } +
+
+ } @code { private bool _ready; + private bool _aiLoading; + private string? _aiAnalysis; + private AIAnalysisResultDTO? _aiAnalysisObj; + private bool _aiLoadedOnce = false; protected override async Task OnAfterRenderAsync(bool firstRender) { @@ -45,6 +154,35 @@ else _ready = true; StateHasChanged(); + if (!_aiLoadedOnce) + { + _aiLoadedOnce = true; + _aiLoading = true; + StateHasChanged(); + + // Get analysis: + + var csvPath = Path.Combine(Env.ContentRootPath, "AIDashboard", "Services", "Reporting", "test_data.csv"); + _aiAnalysis = await ReportingService.AnalyzeCsvAsync(csvPath); + + // Try to deserialize the returned JSON into the DTO so we can render structured cards UI. + if (!string.IsNullOrWhiteSpace(_aiAnalysis)) + { + try + { + var options = new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + _aiAnalysisObj = System.Text.Json.JsonSerializer.Deserialize(_aiAnalysis, options); + } + catch + { + _aiAnalysisObj = null; + } + } + + _aiLoading = false; + StateHasChanged(); + } + } private void NavigateToSignIn() diff --git a/CulinaryCommandApp/CulinaryCommand.csproj b/CulinaryCommandApp/CulinaryCommand.csproj index 1f8d65f..d0082d1 100644 --- a/CulinaryCommandApp/CulinaryCommand.csproj +++ b/CulinaryCommandApp/CulinaryCommand.csproj @@ -12,6 +12,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CulinaryCommandApp/Program.cs b/CulinaryCommandApp/Program.cs index 23cf109..0834f07 100644 --- a/CulinaryCommandApp/Program.cs +++ b/CulinaryCommandApp/Program.cs @@ -8,6 +8,9 @@ using System; // for Version, TimeSpan using System.Linq; using CulinaryCommand.Components; // for args.Any +using Google.GenAI; +using CulinaryCommandApp.AIDashboard.Services.Reporting; + var builder = WebApplication.CreateBuilder(args); @@ -20,6 +23,11 @@ builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); +// Register Google GenAI client and AIReportingService so they can be injected. +// The Client will pick up the GOOGLE_API_KEY from environment variables (set in deploy.yml). +builder.Services.AddSingleton(_ => new Client()); +builder.Services.AddScoped(); + // DB hookup // var conn = builder.Configuration.GetConnectionString("DefaultConnection"); // if (string.IsNullOrWhiteSpace(conn)) diff --git a/docs/images/DASHBOARD_Weekly_Report_Analysis.png b/docs/images/DASHBOARD_Weekly_Report_Analysis.png new file mode 100644 index 0000000..492ff7a Binary files /dev/null and b/docs/images/DASHBOARD_Weekly_Report_Analysis.png differ diff --git a/docs/index.md b/docs/index.md index 41e68f7..cea7491 100644 --- a/docs/index.md +++ b/docs/index.md @@ -31,9 +31,65 @@ All related information to documentation is located under the `docs/` directory. ## Table of Contents - [Terraform](#terraform) +- [Analytics Reporting Dashboard](#dashboard) ## Terraform {#terraform} [Terraform](https://developer.hashicorp.com/terraform) is an Infrastructure as Code (IaC) tool that deploys all AWS related infrastructure. This is automatically done through the CI/CD pipeline. -Currently, the only resource that Terraform deploys is the lightsail instance that the Culinary Commmand app is hosted on. \ No newline at end of file +
+ + + + +
+Files + +The Terraform configuration for this project lives in the repository's `terraform/` folder. Key files you will find here: + +- `main.tf` — primary configuration and resource definitions. +- `lightsail.tf` — contains provider/service specific resources. +- `variables.tf` — documents configurable inputs. +- `outputs.tf` — exposes useful values for downstream use (useful for CI/CD and app config). +- `provider.tf` or backend configuration — (if present) provider and state backend settings. + + +
+ + +
+Purpose + +This directory provisions the project's cloud environment. As of now, it is responsible for deploying the lightsail instance that we use to host Culinary Command. + +
+ + +
+Getting started + +The `terraform/` directory will only be changed when there needs to be infrastructure changes. For Culinary Command, it has been configured to automatically deploy infrastructure changes (via CI/CD). To get started, refer to [the official Terraform documentation](https://developer.hashicorp.com/terraform/intro). If you have any questions, reach out to Kevin. +
+ + +
+ + +## Analytics Reporting Dashboard {#dashboard} + +Currently, the `Weekly Report Analysis` section leverages the Gemini API to do static analysis on a CSV file `test_data.csv`. This CSV file contains 100 lines of basic order data. + +In `AIDashboard/Services/Reporting/AIReportingService.cs`, there is a function `AnalyzeCsvAsync` that constructs the prompt, calls the Gemini API using the prompt, and returns the response for us to use. The dashboard component `CulinaryCommandApp/Components/Dashboard.razor` uses this to display the information that we see here: ![alt text](images/DASHBOARD_Weekly_Report_Analysis.png) + +**Note that this is temporary as the information is not reliable.** + +