< Summary

Information
Class: Orchestrator.Commands.Observability.Cost.CostCommand
Assembly: Orchestrator
File(s): /home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Observability/Cost/CostCommand.cs
Line coverage
97%
Covered lines: 301
Uncovered lines: 7
Coverable lines: 308
Total lines: 494
Line coverage: 97.7%
Branch coverage
98%
Covered branches: 240
Total branches: 244
Branch coverage: 98.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
ExecuteAsync()98.08%10510496.25%
ParseMatchdays(...)100%88100%
ParseModels(...)100%1010100%
ParseCommunityContexts(...)100%1010100%
LoadConfigurationFromFile()66.67%6695.45%
MergeConfigurations(...)100%4040100%

File(s)

/home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Observability/Cost/CostCommand.cs

#LineLine coverage
 1using Microsoft.Extensions.Logging;
 2using Spectre.Console.Cli;
 3using Spectre.Console;
 4using System.Globalization;
 5using System.Text.Json;
 6using EHonda.KicktippAi.Core;
 7using Orchestrator.Infrastructure.Factories;
 8
 9namespace Orchestrator.Commands.Observability.Cost;
 10
 11public class CostCommand : AsyncCommand<CostSettings>
 12{
 13    private readonly IAnsiConsole _console;
 14    private readonly IFirebaseServiceFactory _firebaseServiceFactory;
 15    private readonly ILogger<CostCommand> _logger;
 16
 117    public CostCommand(
 118        IAnsiConsole console,
 119        IFirebaseServiceFactory firebaseServiceFactory,
 120        ILogger<CostCommand> logger)
 21    {
 122        _console = console;
 123        _firebaseServiceFactory = firebaseServiceFactory;
 124        _logger = logger;
 125    }
 26
 27    public override async Task<int> ExecuteAsync(CommandContext context, CostSettings settings)
 28    {
 29
 30        try
 31        {
 32            // Load configuration from file if specified
 133            if (!string.IsNullOrWhiteSpace(settings.ConfigFile))
 34            {
 135                var fileConfig = await LoadConfigurationFromFile(settings.ConfigFile);
 136                settings = MergeConfigurations(fileConfig, settings);
 37            }
 38
 39            // Create Firebase services using factory (factory handles env var loading)
 140            var predictionRepository = _firebaseServiceFactory.CreatePredictionRepository();
 41
 142            _console.MarkupLine($"[green]Cost command initialized[/]");
 43
 144            if (settings.Verbose)
 45            {
 146                _console.MarkupLine("[dim]Verbose mode enabled[/]");
 47            }
 48
 149            if (settings.All)
 50            {
 151                _console.MarkupLine("[blue]All mode enabled - aggregating over all available data[/]");
 52            }
 53
 54            // Parse filter parameters
 155            var matchdays = ParseMatchdays(settings);
 156            var models = ParseModels(settings);
 157            var communityContexts = ParseCommunityContexts(settings);
 58
 59            // Get available models and community contexts if not specified
 160            var availableModels = models ?? await predictionRepository.GetAvailableModelsAsync();
 161            var availableCommunityContexts = communityContexts ?? await predictionRepository.GetAvailableCommunityContex
 162            var availableMatchdays = matchdays ?? await predictionRepository.GetAvailableMatchdaysAsync();
 63
 164            if (settings.Verbose)
 65            {
 166                _console.MarkupLine($"[dim]Filters:[/]");
 167                _console.MarkupLine($"[dim]  Matchdays: {(matchdays?.Any() == true ? string.Join(", ", matchdays) : $"al
 168                _console.MarkupLine($"[dim]  Models: {(models?.Any() == true ? string.Join(", ", models) : $"all ({avail
 169                _console.MarkupLine($"[dim]  Community Contexts: {(communityContexts?.Any() == true ? string.Join(", ", 
 170                _console.MarkupLine($"[dim]  Include Bonus: {settings.Bonus || settings.All}[/]");
 71            }
 72
 73            // Calculate costs
 174            var totalCost = 0.0;
 175            var matchPredictionCost = 0.0;
 176            var bonusPredictionCost = 0.0;
 177            var matchPredictionCount = 0;
 178            var bonusPredictionCount = 0;
 79
 80            // Structure to store detailed breakdown data with reprediction index support
 181            var detailedData = new List<(string CommunityContext, string Model, string Category, int RepredictionIndex, 
 82
 183            _console.Status()
 184                .Spinner(Spinner.Known.Dots)
 185                .Start("Calculating costs...", ctx =>
 186                {
 187                    foreach (var model in availableModels)
 188                    {
 189                        foreach (var communityContext in availableCommunityContexts)
 190                        {
 191                            ctx.Status($"Processing {model} - {communityContext}...");
 192
 193                            if (settings.Verbose)
 194                            {
 195                                _console.MarkupLine($"[dim]  Processing model: {model}, community context: {communityCon
 196                            }
 197
 198                            // Get match prediction costs by reprediction index
 199                            var matchCostsByIndex = predictionRepository.GetMatchPredictionCostsByRepredictionIndexAsync
 1100
 1101                            foreach (var kvp in matchCostsByIndex)
 1102                            {
 1103                                var repredictionIndex = kvp.Key;
 1104                                var (cost, count) = kvp.Value;
 1105
 1106                                matchPredictionCost += cost;
 1107                                matchPredictionCount += count;
 1108
 1109                                // Store detailed data for breakdown
 1110                                if (settings.DetailedBreakdown && (cost > 0 || count > 0))
 1111                                {
 1112                                    detailedData.Add((communityContext, model, "Match", repredictionIndex, count, cost))
 1113                                }
 1114
 1115                                if (settings.Verbose && (cost > 0 || count > 0))
 1116                                {
 1117                                    _console.MarkupLine($"[dim]    Match predictions (reprediction {repredictionIndex}):
 1118                                }
 1119                            }
 1120
 1121                            // Get bonus prediction costs if requested or if all mode is enabled
 1122                            if (settings.Bonus || settings.All)
 1123                            {
 1124                                var bonusCostsByIndex = predictionRepository.GetBonusPredictionCostsByRepredictionIndexA
 1125
 1126                                foreach (var kvp in bonusCostsByIndex)
 1127                                {
 1128                                    var repredictionIndex = kvp.Key;
 1129                                    var (cost, count) = kvp.Value;
 1130
 1131                                    bonusPredictionCost += cost;
 1132                                    bonusPredictionCount += count;
 1133
 1134                                    // Store detailed data for breakdown
 1135                                    if (settings.DetailedBreakdown && (cost > 0 || count > 0))
 1136                                    {
 1137                                        detailedData.Add((communityContext, model, "Bonus", repredictionIndex, count, co
 1138                                    }
 1139
 1140                                    if (settings.Verbose && (cost > 0 || count > 0))
 1141                                    {
 1142                                        _console.MarkupLine($"[dim]    Bonus predictions (reprediction {repredictionInde
 1143                                    }
 1144                                }
 1145                            }
 1146                        }
 1147                    }
 1148                });
 149
 1150            totalCost = matchPredictionCost + bonusPredictionCost;
 151
 152            // Display results
 1153            var table = new Table();
 1154            table.Border(TableBorder.Rounded);
 155
 1156            if (settings.DetailedBreakdown)
 157            {
 158                // Add columns for detailed breakdown with reprediction support
 1159                table.AddColumn("Community Context");
 1160                table.AddColumn("Model");
 1161                table.AddColumn("Category");
 1162                table.AddColumn("Index 0", col => col.RightAligned());
 1163                table.AddColumn("Index 1", col => col.RightAligned());
 1164                table.AddColumn("Index 2+", col => col.RightAligned());
 1165                table.AddColumn("Total Count", col => col.RightAligned());
 1166                table.AddColumn("Total Cost (USD)", col => col.RightAligned());
 167
 168                // Group data by community context, model, and category to aggregate reprediction indices
 1169                var groupedData = detailedData
 1170                    .GroupBy(d => new { d.CommunityContext, d.Model, d.Category })
 1171                    .Select(g => new
 1172                    {
 1173                        g.Key.CommunityContext,
 1174                        g.Key.Model,
 1175                        g.Key.Category,
 1176                        Index0Count = g.Where(x => x.RepredictionIndex == 0).Sum(x => x.Count),
 1177                        Index0Cost = g.Where(x => x.RepredictionIndex == 0).Sum(x => x.Cost),
 1178                        Index1Count = g.Where(x => x.RepredictionIndex == 1).Sum(x => x.Count),
 1179                        Index1Cost = g.Where(x => x.RepredictionIndex == 1).Sum(x => x.Cost),
 1180                        Index2PlusCount = g.Where(x => x.RepredictionIndex >= 2).Sum(x => x.Count),
 1181                        Index2PlusCost = g.Where(x => x.RepredictionIndex >= 2).Sum(x => x.Cost),
 1182                        TotalCount = g.Sum(x => x.Count),
 1183                        TotalCost = g.Sum(x => x.Cost)
 1184                    })
 1185                    .OrderBy(g => g.CommunityContext)
 1186                    .ThenBy(g => g.Model)
 1187                    .ThenBy(g => g.Category)
 1188                    .ToList();
 189
 190                // Add rows for detailed breakdown with alternating styling
 1191                for (int i = 0; i < groupedData.Count; i++)
 192                {
 1193                    var data = groupedData[i];
 1194                    var isEvenRow = i % 2 == 0;
 195
 1196                    var index0Text = data.Index0Count > 0 ? $"{data.Index0Count} (${data.Index0Cost.ToString("F2", Cultu
 1197                    var index1Text = data.Index1Count > 0 ? $"{data.Index1Count} (${data.Index1Cost.ToString("F2", Cultu
 1198                    var index2PlusText = data.Index2PlusCount > 0 ? $"{data.Index2PlusCount} (${data.Index2PlusCost.ToSt
 199
 1200                    if (isEvenRow)
 201                    {
 202                        // Even rows - normal styling
 1203                        table.AddRow(
 1204                            data.CommunityContext,
 1205                            data.Model,
 1206                            data.Category,
 1207                            index0Text,
 1208                            index1Text,
 1209                            index2PlusText,
 1210                            data.TotalCount.ToString(CultureInfo.InvariantCulture),
 1211                            $"${data.TotalCost.ToString("F4", CultureInfo.InvariantCulture)}"
 1212                        );
 213                    }
 214                    else
 215                    {
 216                        // Odd rows - subtle blue tint for visual differentiation
 1217                        table.AddRow(
 1218                            $"[blue]{data.CommunityContext}[/]",
 1219                            $"[blue]{data.Model}[/]",
 1220                            $"[blue]{data.Category}[/]",
 1221                            $"[blue]{index0Text}[/]",
 1222                            $"[blue]{index1Text}[/]",
 1223                            $"[blue]{index2PlusText}[/]",
 1224                            $"[blue]{data.TotalCount.ToString(CultureInfo.InvariantCulture)}[/]",
 1225                            $"[blue]${data.TotalCost.ToString("F4", CultureInfo.InvariantCulture)}[/]"
 1226                        );
 227                    }
 228                }
 229
 230                // Add total row
 1231                if (detailedData.Any())
 232                {
 233                    // Calculate totals by reprediction index
 1234                    var totalIndex0Count = detailedData.Where(x => x.RepredictionIndex == 0).Sum(x => x.Count);
 1235                    var totalIndex0Cost = detailedData.Where(x => x.RepredictionIndex == 0).Sum(x => x.Cost);
 1236                    var totalIndex1Count = detailedData.Where(x => x.RepredictionIndex == 1).Sum(x => x.Count);
 1237                    var totalIndex1Cost = detailedData.Where(x => x.RepredictionIndex == 1).Sum(x => x.Cost);
 1238                    var totalIndex2PlusCount = detailedData.Where(x => x.RepredictionIndex >= 2).Sum(x => x.Count);
 1239                    var totalIndex2PlusCost = detailedData.Where(x => x.RepredictionIndex >= 2).Sum(x => x.Cost);
 240
 1241                    var totalIndex0Text = totalIndex0Count > 0 ? $"{totalIndex0Count} (${totalIndex0Cost.ToString("F2", 
 1242                    var totalIndex1Text = totalIndex1Count > 0 ? $"{totalIndex1Count} (${totalIndex1Cost.ToString("F2", 
 1243                    var totalIndex2PlusText = totalIndex2PlusCount > 0 ? $"{totalIndex2PlusCount} (${totalIndex2PlusCost
 244
 1245                    table.AddEmptyRow();
 1246                    table.AddRow(
 1247                        "[bold]Total[/]",
 1248                        "",
 1249                        "",
 1250                        $"[bold]{totalIndex0Text}[/]",
 1251                        $"[bold]{totalIndex1Text}[/]",
 1252                        $"[bold]{totalIndex2PlusText}[/]",
 1253                        $"[bold]{(matchPredictionCount + bonusPredictionCount).ToString(CultureInfo.InvariantCulture)}[/
 1254                        $"[bold]${totalCost.ToString("F4", CultureInfo.InvariantCulture)}[/]"
 1255                    );
 256                }
 257            }
 258            else
 259            {
 260                // Standard summary table
 1261                table.AddColumn("Category");
 1262                table.AddColumn("Count", col => col.RightAligned());
 1263                table.AddColumn("Cost (USD)", col => col.RightAligned());
 264
 1265                var rowIndex = 0;
 266
 267                // Add Match row
 1268                var isEvenRow = rowIndex % 2 == 0;
 1269                if (isEvenRow)
 270                {
 1271                    table.AddRow("Match", matchPredictionCount.ToString(CultureInfo.InvariantCulture), $"${matchPredicti
 272                }
 273                else
 274                {
 0275                    table.AddRow(
 0276                        "[blue]Match[/]",
 0277                        $"[blue]{matchPredictionCount.ToString(CultureInfo.InvariantCulture)}[/]",
 0278                        $"[blue]${matchPredictionCost.ToString("F4", CultureInfo.InvariantCulture)}[/]"
 0279                    );
 280                }
 1281                rowIndex++;
 282
 283                // Add Bonus row if applicable
 1284                if (settings.Bonus || settings.All)
 285                {
 1286                    isEvenRow = rowIndex % 2 == 0;
 1287                    if (isEvenRow)
 288                    {
 0289                        table.AddRow("Bonus", bonusPredictionCount.ToString(CultureInfo.InvariantCulture), $"${bonusPred
 290                    }
 291                    else
 292                    {
 1293                        table.AddRow(
 1294                            "[blue]Bonus[/]",
 1295                            $"[blue]{bonusPredictionCount.ToString(CultureInfo.InvariantCulture)}[/]",
 1296                            $"[blue]${bonusPredictionCost.ToString("F4", CultureInfo.InvariantCulture)}[/]"
 1297                        );
 298                    }
 299                }
 300
 1301                table.AddEmptyRow();
 1302                table.AddRow("[bold]Total[/]", $"[bold]{(matchPredictionCount + bonusPredictionCount).ToString(CultureIn
 303            }
 304
 1305            _console.Write(table);
 306
 1307            _console.MarkupLine($"[green]✓ Cost calculation completed[/]");
 308
 1309            return 0;
 310        }
 1311        catch (Exception ex)
 312        {
 1313            _logger.LogError(ex, "Failed to calculate costs");
 1314            _console.MarkupLine($"[red]✗ Failed to calculate costs: {ex.Message}[/]");
 1315            return 1;
 316        }
 1317    }
 318
 319    private List<int>? ParseMatchdays(CostSettings settings)
 320    {
 1321        if (settings.All || string.IsNullOrWhiteSpace(settings.Matchdays))
 1322            return null; // null means all matchdays
 323
 1324        if (settings.Matchdays.Trim().ToLowerInvariant() == "all")
 1325            return null;
 326
 327        try
 328        {
 1329            return settings.Matchdays
 1330                .Split(',', StringSplitOptions.RemoveEmptyEntries)
 1331                .Select(md => int.Parse(md.Trim()))
 1332                .ToList();
 333        }
 1334        catch (FormatException)
 335        {
 1336            throw new ArgumentException($"Invalid matchday format: {settings.Matchdays}. Use comma-separated numbers (e.
 337        }
 1338    }
 339
 340    private List<string>? ParseModels(CostSettings settings)
 341    {
 1342        if (settings.All || string.IsNullOrWhiteSpace(settings.Models))
 1343            return null; // null means all models
 344
 1345        if (settings.Models.Trim().ToLowerInvariant() == "all")
 1346            return null;
 347
 1348        return settings.Models
 1349            .Split(',', StringSplitOptions.RemoveEmptyEntries)
 1350            .Select(m => m.Trim())
 1351            .Where(m => !string.IsNullOrWhiteSpace(m))
 1352            .ToList();
 353    }
 354
 355    private List<string>? ParseCommunityContexts(CostSettings settings)
 356    {
 1357        if (settings.All || string.IsNullOrWhiteSpace(settings.CommunityContexts))
 1358            return null; // null means all community contexts
 359
 1360        if (settings.CommunityContexts.Trim().ToLowerInvariant() == "all")
 1361            return null;
 362
 1363        return settings.CommunityContexts
 1364            .Split(',', StringSplitOptions.RemoveEmptyEntries)
 1365            .Select(cc => cc.Trim())
 1366            .Where(cc => !string.IsNullOrWhiteSpace(cc))
 1367            .ToList();
 368    }
 369
 370    private async Task<CostConfiguration> LoadConfigurationFromFile(string configFilePath)
 371    {
 372        try
 373        {
 374            // Resolve relative paths
 1375            var resolvedPath = Path.IsPathRooted(configFilePath)
 1376                ? configFilePath
 1377                : Path.Combine(Directory.GetCurrentDirectory(), configFilePath);
 378
 1379            if (!File.Exists(resolvedPath))
 380            {
 1381                throw new FileNotFoundException($"Configuration file not found: {resolvedPath}");
 382            }
 383
 1384            _logger.LogInformation("Loading configuration from: {ConfigPath}", resolvedPath);
 385
 1386            var jsonContent = await File.ReadAllTextAsync(resolvedPath);
 1387            var options = new JsonSerializerOptions
 1388            {
 1389                PropertyNameCaseInsensitive = true,
 1390                AllowTrailingCommas = true,
 1391                ReadCommentHandling = JsonCommentHandling.Skip
 1392            };
 393
 1394            var config = JsonSerializer.Deserialize<CostConfiguration>(jsonContent, options);
 1395            if (config == null)
 396            {
 0397                throw new InvalidOperationException($"Failed to deserialize configuration from: {resolvedPath}");
 398            }
 399
 1400            return config;
 401        }
 1402        catch (JsonException ex)
 403        {
 1404            throw new InvalidOperationException($"Invalid JSON in configuration file: {configFilePath}. {ex.Message}", e
 405        }
 1406        catch (Exception ex)
 407        {
 1408            throw new InvalidOperationException($"Failed to load configuration from file: {configFilePath}. {ex.Message}
 409        }
 1410    }
 411
 412    private CostSettings MergeConfigurations(CostConfiguration fileConfig, CostSettings cliSettings)
 413    {
 1414        _logger.LogInformation("Merging file configuration with command line options (CLI options take precedence)");
 415
 416        // Create a new settings object with file config as base, CLI overrides
 1417        var mergedSettings = new CostSettings();
 418
 419        // Apply file config first (if values are not null/default)
 1420        if (!string.IsNullOrWhiteSpace(fileConfig.Matchdays))
 1421            mergedSettings.Matchdays = fileConfig.Matchdays;
 422
 1423        if (fileConfig.Bonus.HasValue)
 1424            mergedSettings.Bonus = fileConfig.Bonus.Value;
 425
 1426        if (!string.IsNullOrWhiteSpace(fileConfig.Models))
 1427            mergedSettings.Models = fileConfig.Models;
 428
 1429        if (!string.IsNullOrWhiteSpace(fileConfig.CommunityContexts))
 1430            mergedSettings.CommunityContexts = fileConfig.CommunityContexts;
 431
 1432        if (fileConfig.All.HasValue)
 1433            mergedSettings.All = fileConfig.All.Value;
 434
 1435        if (fileConfig.Verbose.HasValue)
 1436            mergedSettings.Verbose = fileConfig.Verbose.Value;
 437
 1438        if (fileConfig.DetailedBreakdown.HasValue)
 1439            mergedSettings.DetailedBreakdown = fileConfig.DetailedBreakdown.Value;
 440
 441        // Override with CLI settings (non-default values)
 1442        if (!string.IsNullOrWhiteSpace(cliSettings.Matchdays))
 443        {
 1444            mergedSettings.Matchdays = cliSettings.Matchdays;
 1445            if (mergedSettings.Verbose)
 1446                _logger.LogInformation("CLI override: Matchdays = {Value}", cliSettings.Matchdays);
 447        }
 448
 1449        if (cliSettings.Bonus) // Only override if explicitly set to true
 450        {
 1451            mergedSettings.Bonus = cliSettings.Bonus;
 1452            if (mergedSettings.Verbose)
 1453                _logger.LogInformation("CLI override: Bonus = {Value}", cliSettings.Bonus);
 454        }
 455
 1456        if (!string.IsNullOrWhiteSpace(cliSettings.Models))
 457        {
 1458            mergedSettings.Models = cliSettings.Models;
 1459            if (mergedSettings.Verbose)
 1460                _logger.LogInformation("CLI override: Models = {Value}", cliSettings.Models);
 461        }
 462
 1463        if (!string.IsNullOrWhiteSpace(cliSettings.CommunityContexts))
 464        {
 1465            mergedSettings.CommunityContexts = cliSettings.CommunityContexts;
 1466            if (mergedSettings.Verbose)
 1467                _logger.LogInformation("CLI override: CommunityContexts = {Value}", cliSettings.CommunityContexts);
 468        }
 469
 1470        if (cliSettings.All) // Only override if explicitly set to true
 471        {
 1472            mergedSettings.All = cliSettings.All;
 1473            if (mergedSettings.Verbose)
 1474                _logger.LogInformation("CLI override: All = {Value}", cliSettings.All);
 475        }
 476
 1477        if (cliSettings.Verbose) // Only override if explicitly set to true
 478        {
 1479            mergedSettings.Verbose = cliSettings.Verbose;
 480        }
 481
 1482        if (cliSettings.DetailedBreakdown) // Only override if explicitly set to true
 483        {
 1484            mergedSettings.DetailedBreakdown = cliSettings.DetailedBreakdown;
 1485            if (mergedSettings.Verbose)
 1486                _logger.LogInformation("CLI override: DetailedBreakdown = {Value}", cliSettings.DetailedBreakdown);
 487        }
 488
 489        // Always preserve the ConfigFile setting
 1490        mergedSettings.ConfigFile = cliSettings.ConfigFile;
 491
 1492        return mergedSettings;
 493    }
 494}