< Summary

Information
Class: Orchestrator.Commands.Operations.Verify.VerifyBonusCommand
Assembly: Orchestrator
File(s): /home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Operations/Verify/VerifyBonusCommand.cs
Line coverage
99%
Covered lines: 170
Uncovered lines: 1
Coverable lines: 171
Total lines: 388
Line coverage: 99.4%
Branch coverage
95%
Covered branches: 103
Total branches: 108
Branch coverage: 95.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()100%88100%
ExecuteVerificationWorkflow()96.77%6262100%
ValidateBonusPrediction(...)100%1010100%
CheckBonusPredictionOutdated()100%1616100%
CompareBonusPredictions(...)75%121285.71%

File(s)

/home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Operations/Verify/VerifyBonusCommand.cs

#LineLine coverage
 1using EHonda.KicktippAi.Core;
 2using Microsoft.Extensions.Logging;
 3using Spectre.Console.Cli;
 4using Spectre.Console;
 5using KicktippIntegration;
 6using Orchestrator.Infrastructure.Factories;
 7
 8namespace Orchestrator.Commands.Operations.Verify;
 9
 10public class VerifyBonusCommand : AsyncCommand<VerifySettings>
 11{
 12    private readonly IAnsiConsole _console;
 13    private readonly IFirebaseServiceFactory _firebaseServiceFactory;
 14    private readonly IKicktippClientFactory _kicktippClientFactory;
 15    private readonly ILogger<VerifyBonusCommand> _logger;
 16
 117    public VerifyBonusCommand(
 118        IAnsiConsole console,
 119        IFirebaseServiceFactory firebaseServiceFactory,
 120        IKicktippClientFactory kicktippClientFactory,
 121        ILogger<VerifyBonusCommand> logger)
 22    {
 123        _console = console;
 124        _firebaseServiceFactory = firebaseServiceFactory;
 125        _kicktippClientFactory = kicktippClientFactory;
 126        _logger = logger;
 127    }
 28
 29    public override async Task<int> ExecuteAsync(CommandContext context, VerifySettings settings)
 30    {
 31
 32        try
 33        {
 134            _console.MarkupLine($"[green]Verify bonus command initialized[/]");
 35
 136            if (settings.Verbose)
 37            {
 138                _console.MarkupLine("[dim]Verbose mode enabled[/]");
 39            }
 40
 141            if (settings.Agent)
 42            {
 143                _console.MarkupLine("[blue]Agent mode enabled - prediction details will be hidden[/]");
 44            }
 45
 146            if (settings.InitMatchday)
 47            {
 148                _console.MarkupLine("[cyan]Init bonus mode enabled - will return error if no predictions exist[/]");
 49            }
 50
 151            if (settings.CheckOutdated)
 52            {
 153                _console.MarkupLine("[cyan]Outdated check enabled - predictions will be checked against latest context d
 54            }
 55
 56            // Execute the verification workflow
 157            var hasDiscrepancies = await ExecuteVerificationWorkflow(settings);
 58
 159            return hasDiscrepancies ? 1 : 0;
 60        }
 161        catch (Exception ex)
 62        {
 163            _logger.LogError(ex, "Error executing verify bonus command");
 164            _console.MarkupLine($"[red]Error:[/] {ex.Message}");
 165            return 1;
 66        }
 167    }
 68
 69    private async Task<bool> ExecuteVerificationWorkflow(VerifySettings settings)
 70    {
 171        var kicktippClient = _kicktippClientFactory.CreateClient();
 72
 73        // Try to get the prediction repository (may be null if Firebase is not configured)
 174        var predictionRepository = _firebaseServiceFactory.CreatePredictionRepository();
 175        if (predictionRepository == null)
 76        {
 177            _console.MarkupLine("[red]Error: Database not configured. Cannot verify predictions without database access.
 178            _console.MarkupLine("[yellow]Hint: Set FIREBASE_PROJECT_ID and FIREBASE_SERVICE_ACCOUNT_JSON environment var
 179            return true; // Consider this a failure
 80        }
 81
 82        // Get KPI repository for outdated checks (required for bonus predictions)
 183        var kpiRepository = _firebaseServiceFactory.CreateKpiRepository();
 84
 85        // Determine community context (use explicit setting or fall back to community name)
 186        string communityContext = settings.CommunityContext ?? settings.Community;
 87
 188        _console.MarkupLine($"[blue]Using community:[/] [yellow]{settings.Community}[/]");
 189        _console.MarkupLine($"[blue]Using community context:[/] [yellow]{communityContext}[/]");
 190        _console.MarkupLine("[blue]Getting open bonus questions from Kicktipp...[/]");
 91
 92        // Step 1: Get open bonus questions from Kicktipp
 193        var bonusQuestions = await kicktippClient.GetOpenBonusQuestionsAsync(settings.Community);
 94
 195        if (!bonusQuestions.Any())
 96        {
 197            _console.MarkupLine("[yellow]No bonus questions found on Kicktipp[/]");
 198            return false;
 99        }
 100
 1101        _console.MarkupLine($"[green]Found {bonusQuestions.Count} bonus questions on Kicktipp[/]");
 102
 1103        _console.MarkupLine("[blue]Getting placed bonus predictions from Kicktipp...[/]");
 104
 105        // Step 1.5: Get currently placed predictions from Kicktipp
 1106        var placedPredictions = await kicktippClient.GetPlacedBonusPredictionsAsync(settings.Community);
 107
 1108        _console.MarkupLine("[blue]Retrieving predictions from database...[/]");
 109
 1110        var hasDiscrepancies = false;
 1111        var totalQuestions = 0;
 1112        var questionsWithDatabasePredictions = 0;
 1113        var validPredictions = 0;
 114
 115        // Step 2: For each bonus question, check if we have a prediction in database
 1116        foreach (var question in bonusQuestions)
 117        {
 1118            totalQuestions++;
 119
 120            try
 121            {
 122                // Get prediction from database
 1123                if (settings.Verbose)
 124                {
 1125                    _console.MarkupLine($"[dim]  Looking up: {Markup.Escape(question.Text)}[/]");
 126                }
 127
 1128                var databasePrediction = await predictionRepository.GetBonusPredictionByTextAsync(question.Text, setting
 1129                var kicktippPrediction = placedPredictions.GetValueOrDefault(question.FormFieldName ?? question.Text);
 130
 1131                if (databasePrediction != null)
 132                {
 1133                    questionsWithDatabasePredictions++;
 134
 135                    // Validate the prediction against the question
 1136                    var isValidPrediction = ValidateBonusPrediction(question, databasePrediction);
 137
 138                    // Compare database prediction with Kicktipp placed prediction
 1139                    var predictionsMatch = CompareBonusPredictions(databasePrediction, kicktippPrediction);
 140
 141                    // Check if prediction is outdated (if enabled)
 1142                    var isOutdated = false;
 1143                    if (settings.CheckOutdated)
 144                    {
 1145                        isOutdated = await CheckBonusPredictionOutdated(predictionRepository, kpiRepository, question.Te
 146                    }
 147
 148                    // Consider prediction valid if it passes validation, matches Kicktipp, and is not outdated
 1149                    var isPredictionValid = isValidPrediction && predictionsMatch && !isOutdated;
 150
 1151                    if (isPredictionValid)
 152                    {
 1153                        validPredictions++;
 154
 1155                        if (settings.Verbose)
 156                        {
 1157                            if (settings.Agent)
 158                            {
 1159                                _console.MarkupLine($"[green]✓ {Markup.Escape(question.Text)}[/] [dim](valid)[/]");
 160                            }
 161                            else
 162                            {
 1163                                var optionTexts = question.Options
 1164                                    .Where(o => databasePrediction.SelectedOptionIds.Contains(o.Id))
 1165                                    .Select(o => o.Text);
 1166                                _console.MarkupLine($"[green]✓ {Markup.Escape(question.Text)}:[/] {string.Join(", ", opt
 167                            }
 168                        }
 169                    }
 170                    else
 171                    {
 1172                        hasDiscrepancies = true;
 173
 1174                        if (settings.Agent)
 175                        {
 1176                            var status = !isValidPrediction ? "invalid prediction" :
 1177                                        !predictionsMatch ? "mismatch with Kicktipp" : "outdated";
 1178                            _console.MarkupLine($"[red]✗ {Markup.Escape(question.Text)}[/] [dim]({status})[/]");
 179                        }
 180                        else
 181                        {
 1182                            if (!isValidPrediction)
 183                            {
 1184                                var optionTexts = question.Options
 1185                                    .Where(o => databasePrediction.SelectedOptionIds.Contains(o.Id))
 1186                                    .Select(o => o.Text);
 1187                                _console.MarkupLine($"[red]✗ {Markup.Escape(question.Text)}:[/] {string.Join(", ", optio
 188                            }
 1189                            else if (!predictionsMatch)
 190                            {
 191                                // Show mismatch details
 1192                                var databaseTexts = question.Options
 1193                                    .Where(o => databasePrediction.SelectedOptionIds.Contains(o.Id))
 1194                                    .Select(o => o.Text);
 1195                                var kicktippTexts = kicktippPrediction != null
 1196                                    ? question.Options
 1197                                        .Where(o => kicktippPrediction.SelectedOptionIds.Contains(o.Id))
 1198                                        .Select(o => o.Text)
 1199                                    : new List<string>();
 200
 1201                                _console.MarkupLine($"[red]✗ {Markup.Escape(question.Text)}:[/]");
 1202                                _console.MarkupLine($"  [yellow]Database:[/] {string.Join(", ", databaseTexts)}");
 1203                                _console.MarkupLine($"  [yellow]Kicktipp:[/] {(kicktippTexts.Any() ? string.Join(", ", k
 204                            }
 1205                            else if (isOutdated)
 206                            {
 1207                                var optionTexts = question.Options
 1208                                    .Where(o => databasePrediction.SelectedOptionIds.Contains(o.Id))
 1209                                    .Select(o => o.Text);
 1210                                _console.MarkupLine($"[red]✗ {Markup.Escape(question.Text)}:[/] {string.Join(", ", optio
 1211                                _console.MarkupLine($"  [yellow]Status:[/] Outdated (context updated after prediction)")
 212                            }
 213                        }
 214                    }
 215                }
 216                else
 217                {
 1218                    hasDiscrepancies = true;
 219
 1220                    if (settings.Verbose)
 221                    {
 1222                        if (settings.Agent)
 223                        {
 1224                            _console.MarkupLine($"[yellow]○ {Markup.Escape(question.Text)}[/] [dim](no prediction)[/]");
 225                        }
 226                        else
 227                        {
 1228                            _console.MarkupLine($"[yellow]○ {Markup.Escape(question.Text)}:[/] [dim](no prediction)[/]")
 229                        }
 230                    }
 231                }
 1232            }
 1233            catch (Exception ex)
 234            {
 1235                hasDiscrepancies = true;
 1236                _logger.LogError(ex, "Error verifying bonus prediction for question '{QuestionText}'", question.Text);
 237
 1238                if (settings.Agent)
 239                {
 1240                    _console.MarkupLine($"[red]✗ {Markup.Escape(question.Text)}[/] [dim](error)[/]");
 241                }
 242                else
 243                {
 1244                    _console.MarkupLine($"[red]✗ {Markup.Escape(question.Text)}:[/] Error during verification");
 245                }
 1246            }
 1247        }
 248
 249        // Step 3: Display summary
 1250        _console.WriteLine();
 1251        _console.MarkupLine("[bold]Verification Summary:[/]");
 1252        _console.MarkupLine($"  Total bonus questions: {totalQuestions}");
 1253        _console.MarkupLine($"  Questions with database predictions: {questionsWithDatabasePredictions}");
 1254        _console.MarkupLine($"  Valid predictions: {validPredictions}");
 255
 256        // Check for init-bonus mode first
 1257        if (settings.InitMatchday && questionsWithDatabasePredictions == 0)
 258        {
 1259            _console.MarkupLine("[yellow]  Init bonus detected - no database predictions exist[/]");
 1260            _console.MarkupLine("[red]Returning error to trigger initial prediction workflow[/]");
 1261            return true; // Return error to trigger workflow
 262        }
 263
 1264        if (hasDiscrepancies)
 265        {
 1266            _console.MarkupLine($"[red]  Missing or invalid predictions: {totalQuestions - validPredictions}[/]");
 1267            _console.MarkupLine("[red]Verification failed - some predictions are missing or invalid[/]");
 268        }
 269        else
 270        {
 1271            _console.MarkupLine("[green]  All predictions are valid - verification successful[/]");
 272        }
 273
 1274        return hasDiscrepancies;
 1275    }
 276
 277    private static bool ValidateBonusPrediction(BonusQuestion question, BonusPrediction prediction)
 278    {
 279        // Check if all selected option IDs exist in the question
 1280        var validOptionIds = question.Options.Select(o => o.Id).ToHashSet();
 1281        var allOptionsValid = prediction.SelectedOptionIds.All(id => validOptionIds.Contains(id));
 282
 1283        if (!allOptionsValid)
 284        {
 1285            return false;
 286        }
 287
 288        // Check if the number of selections is valid
 1289        var selectionCount = prediction.SelectedOptionIds.Count;
 1290        if (selectionCount < 1 || selectionCount > question.MaxSelections)
 291        {
 1292            return false;
 293        }
 294
 295        // Check for duplicates
 1296        var uniqueSelections = prediction.SelectedOptionIds.Distinct().Count();
 1297        if (uniqueSelections != selectionCount)
 298        {
 1299            return false;
 300        }
 301
 1302        return true;
 303    }
 304
 305    private async Task<bool> CheckBonusPredictionOutdated(
 306        IPredictionRepository predictionRepository,
 307        IKpiRepository kpiRepository,
 308        string questionText,
 309        string model,
 310        string communityContext,
 311        bool verbose)
 312    {
 313        try
 314        {
 315            // Get prediction metadata (includes creation timestamp and context document names)
 1316            var predictionMetadata = await predictionRepository.GetBonusPredictionMetadataByTextAsync(
 1317                questionText, model, communityContext);
 318
 1319            if (predictionMetadata == null)
 320            {
 321                // No metadata found, assume not outdated
 1322                return false;
 323            }
 324
 325            // Check if any KPI document has been updated after the prediction was created
 1326            foreach (var contextDocumentName in predictionMetadata.ContextDocumentNames)
 327            {
 1328                var kpiDocument = await kpiRepository.GetKpiDocumentAsync(contextDocumentName, communityContext);
 1329                if (kpiDocument != null)
 330                {
 331                    // Compare the creation timestamps
 332                    // Note: We need to be careful about timezone handling here
 333                    // Both timestamps should be in UTC for proper comparison
 1334                    if (kpiDocument.CreatedAt > predictionMetadata.CreatedAt)
 335                    {
 1336                        if (verbose)
 337                        {
 1338                            _console.MarkupLine($"[yellow]KPI document '{contextDocumentName}' updated after prediction 
 1339                            _console.MarkupLine($"  [dim]Prediction created:[/] {predictionMetadata.CreatedAt:yyyy-MM-dd
 1340                            _console.MarkupLine($"  [dim]KPI document created:[/] {kpiDocument.CreatedAt:yyyy-MM-dd HH:m
 341                        }
 1342                        return true; // Prediction is outdated
 343                    }
 344
 1345                    if (verbose)
 346                    {
 1347                        _console.MarkupLine($"[dim]KPI document '{contextDocumentName}' found, version {kpiDocument.Vers
 348                    }
 349                }
 1350                else if (verbose)
 351                {
 1352                    _console.MarkupLine($"[yellow]Warning: KPI document '{contextDocumentName}' not found[/]");
 353                }
 1354            }
 355
 1356            return false; // No KPI documents are newer than the prediction
 357        }
 1358        catch (Exception ex)
 359        {
 1360            if (verbose)
 361            {
 1362                _console.MarkupLine($"[yellow]Warning: Could not check if prediction is outdated: {ex.Message}[/]");
 363            }
 1364            return false; // Assume not outdated if we can't determine
 365        }
 1366    }
 367
 368    private static bool CompareBonusPredictions(BonusPrediction? databasePrediction, BonusPrediction? kicktippPrediction
 369    {
 370        // Both null - match
 1371        if (databasePrediction == null && kicktippPrediction == null)
 372        {
 0373            return true;
 374        }
 375
 376        // One null, other not - mismatch
 1377        if (databasePrediction == null || kicktippPrediction == null)
 378        {
 1379            return false;
 380        }
 381
 382        // Both have values - compare selected option IDs
 1383        var databaseOptions = databasePrediction.SelectedOptionIds.OrderBy(x => x).ToList();
 1384        var kicktippOptions = kicktippPrediction.SelectedOptionIds.OrderBy(x => x).ToList();
 385
 1386        return databaseOptions.SequenceEqual(kicktippOptions);
 387    }
 388}