< Summary

Information
Class: Orchestrator.Commands.Operations.Verify.VerifyMatchdayCommand
Assembly: Orchestrator
File(s): /home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Operations/Verify/VerifyMatchdayCommand.cs
Line coverage
100%
Covered lines: 175
Uncovered lines: 0
Coverable lines: 175
Total lines: 398
Line coverage: 100%
Branch coverage
98%
Covered branches: 120
Total branches: 122
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()100%88100%
ExecuteVerificationWorkflow()97.06%6868100%
ComparePredictions(...)100%1010100%
CheckPredictionOutdated()100%3232100%
StripDisplaySuffix(...)100%44100%

File(s)

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

#LineLine coverage
 1using EHonda.KicktippAi.Core;
 2using Microsoft.Extensions.Logging;
 3using Spectre.Console.Cli;
 4using Spectre.Console;
 5using KicktippIntegration;
 6using Orchestrator.Commands.Shared;
 7using Orchestrator.Infrastructure;
 8using Orchestrator.Infrastructure.Factories;
 9
 10namespace Orchestrator.Commands.Operations.Verify;
 11
 12public class VerifyMatchdayCommand : AsyncCommand<VerifySettings>
 13{
 14    private readonly IAnsiConsole _console;
 15    private readonly IFirebaseServiceFactory _firebaseServiceFactory;
 16    private readonly IKicktippClientFactory _kicktippClientFactory;
 17    private readonly ILogger<VerifyMatchdayCommand> _logger;
 18
 119    public VerifyMatchdayCommand(
 120        IAnsiConsole console,
 121        IFirebaseServiceFactory firebaseServiceFactory,
 122        IKicktippClientFactory kicktippClientFactory,
 123        ILogger<VerifyMatchdayCommand> logger)
 24    {
 125        _console = console;
 126        _firebaseServiceFactory = firebaseServiceFactory;
 127        _kicktippClientFactory = kicktippClientFactory;
 128        _logger = logger;
 129    }
 30
 31    protected override async Task<int> ExecuteAsync(CommandContext context, VerifySettings settings, CancellationToken c
 32    {
 33
 34        try
 35        {
 136            _console.MarkupLine($"[green]Verify matchday command initialized[/]");
 37
 138            if (settings.Verbose)
 39            {
 140                _console.MarkupLine("[dim]Verbose mode enabled[/]");
 41            }
 42
 143            if (settings.Agent)
 44            {
 145                _console.MarkupLine("[blue]Agent mode enabled - prediction details will be hidden[/]");
 46            }
 47
 148            if (settings.InitMatchday)
 49            {
 150                _console.MarkupLine("[cyan]Init matchday mode enabled - will return error if no predictions exist[/]");
 51            }
 52
 153            if (settings.CheckOutdated)
 54            {
 155                _console.MarkupLine("[cyan]Outdated check enabled - predictions will be checked against latest context d
 56            }
 57
 58            // Execute the verification workflow
 159            var hasDiscrepancies = await ExecuteVerificationWorkflow(settings);
 60
 161            return hasDiscrepancies ? 1 : 0;
 62        }
 163        catch (Exception ex)
 64        {
 165            _logger.LogError(ex, "Error executing verify matchday command");
 166            _console.MarkupLine($"[red]Error:[/] {ex.Message}");
 167            return 1;
 68        }
 169    }
 70
 71    private async Task<bool> ExecuteVerificationWorkflow(VerifySettings settings)
 72    {
 173        var kicktippClient = _kicktippClientFactory.CreateClient();
 174        string communityContext = settings.CommunityContext ?? settings.Community;
 175        var competition = CompetitionResolver.ResolveCompetition(settings.Competition, settings.Community, communityCont
 176        var modelConfig = PredictionServiceCommandSupport.CreateModelConfig(settings.Model, settings.ReasoningEffort);
 177        var repositoryCompetition = CompetitionResolver.ToRepositoryCompetitionArgument(competition);
 78
 79        // Try to get the prediction repository (may be null if Firebase is not configured)
 180        var predictionRepository = _firebaseServiceFactory.CreatePredictionRepository(repositoryCompetition);
 181        if (predictionRepository == null)
 82        {
 183            _console.MarkupLine("[red]Error: Database not configured. Cannot verify predictions without database access.
 184            _console.MarkupLine("[yellow]Hint: Set FIREBASE_PROJECT_ID and FIREBASE_SERVICE_ACCOUNT_JSON environment var
 185            return true; // Consider this a failure
 86        }
 87
 88        // Get context repository for outdated checks (may be null if Firebase is not configured)
 189        var contextRepository = _firebaseServiceFactory.CreateContextRepository(repositoryCompetition);
 190        if (settings.CheckOutdated && contextRepository == null)
 91        {
 192            _console.MarkupLine("[red]Error: Database not configured. Cannot check outdated predictions without database
 193            _console.MarkupLine("[yellow]Hint: Set FIREBASE_PROJECT_ID and FIREBASE_SERVICE_ACCOUNT_JSON environment var
 194            return true; // Consider this a failure
 95        }
 96
 197        _console.MarkupLine($"[blue]Using community:[/] [yellow]{settings.Community}[/]");
 198        _console.MarkupLine($"[blue]Using community context:[/] [yellow]{communityContext}[/]");
 199        _console.MarkupLine($"[blue]Using competition:[/] [yellow]{competition}[/]");
 1100        _console.MarkupLine($"[blue]Using model config:[/] [yellow]{modelConfig.DisplayName}[/]");
 1101        _console.MarkupLine("[blue]Getting placed predictions from Kicktipp...[/]");
 102
 103        // Step 1: Get placed predictions from Kicktipp
 1104        var placedPredictions = await kicktippClient.GetPlacedPredictionsAsync(settings.Community);
 105
 1106        if (!placedPredictions.Any())
 107        {
 1108            _console.MarkupLine("[yellow]No matches found on Kicktipp[/]");
 1109            return false;
 110        }
 111
 1112        _console.MarkupLine($"[green]Found {placedPredictions.Count} matches on Kicktipp[/]");
 113
 1114        _console.MarkupLine("[blue]Retrieving predictions from database...[/]");
 115
 1116        var hasDiscrepancies = false;
 1117        var totalMatches = 0;
 1118        var matchesWithPlacedPredictions = 0;
 1119        var matchesWithDatabasePredictions = 0;
 1120        var matchingPredictions = 0;
 121
 122        // Step 2: For each match, compare with database predictions
 1123        foreach (var (match, kicktippPrediction) in placedPredictions)
 124        {
 1125            totalMatches++;
 126
 127            try
 128            {
 129                Prediction? databasePrediction;
 130
 131                // For cancelled matches, use team-names-only lookup to handle startsAt inconsistencies
 132                // See IPredictionRepository.cs for detailed documentation on this edge case
 1133                if (match.IsCancelled)
 134                {
 1135                    if (settings.Verbose)
 136                    {
 1137                        _console.MarkupLine($"[dim]  Looking up (cancelled match, team-names-only): {match.HomeTeam} vs 
 138                    }
 1139                    databasePrediction = await predictionRepository.GetCancelledMatchPredictionAsync(
 1140                        match.HomeTeam, match.AwayTeam, modelConfig, communityContext);
 141                }
 142                else
 143                {
 1144                    if (settings.Verbose)
 145                    {
 1146                        _console.MarkupLine($"[dim]  Looking up: {match.HomeTeam} vs {match.AwayTeam} at {match.StartsAt
 147                    }
 1148                    databasePrediction = await predictionRepository.GetPredictionAsync(match, modelConfig, communityCont
 149                }
 150
 1151                if (kicktippPrediction != null)
 152                {
 1153                    matchesWithPlacedPredictions++;
 154                }
 155
 1156                if (databasePrediction != null)
 157                {
 1158                    matchesWithDatabasePredictions++;
 1159                    if (settings.Verbose && !settings.Agent)
 160                    {
 1161                        _console.MarkupLine($"[dim]  Found database prediction: {databasePrediction.HomeGoals}:{database
 162                    }
 163                }
 1164                else if (settings.Verbose && !settings.Agent)
 165                {
 1166                    _console.MarkupLine($"[dim]  No database prediction found[/]");
 167                }
 168
 169                // Check if prediction is outdated (if enabled and context repository is available)
 1170                var isOutdated = false;
 1171                if (settings.CheckOutdated && contextRepository != null && databasePrediction != null)
 172                {
 1173                    isOutdated = await CheckPredictionOutdated(predictionRepository, contextRepository, match, modelConf
 174                }
 175
 176                // Compare predictions
 1177                var isMatchingPrediction = ComparePredictions(kicktippPrediction, databasePrediction);
 178
 179                // Consider prediction invalid if it's outdated or mismatched
 1180                var isValidPrediction = isMatchingPrediction && !isOutdated;
 181
 1182                if (isValidPrediction)
 183                {
 1184                    matchingPredictions++;
 185
 1186                    if (settings.Verbose)
 187                    {
 1188                        if (settings.Agent)
 189                        {
 1190                            _console.MarkupLine($"[green]✓ {match.HomeTeam} vs {match.AwayTeam}[/] [dim](valid)[/]");
 191                        }
 192                        else
 193                        {
 1194                            var predictionText = kicktippPrediction?.ToString() ?? "no prediction";
 1195                            _console.MarkupLine($"[green]✓ {match.HomeTeam} vs {match.AwayTeam}:[/] {predictionText} [di
 196                        }
 197                    }
 198                }
 199                else
 200                {
 1201                    hasDiscrepancies = true;
 202
 1203                    if (settings.Agent)
 204                    {
 1205                        var reason = isOutdated ? "outdated" : "mismatch";
 1206                        _console.MarkupLine($"[red]✗ {match.HomeTeam} vs {match.AwayTeam}[/] [dim]({reason})[/]");
 207                    }
 208                    else
 209                    {
 1210                        var kicktippText = kicktippPrediction?.ToString() ?? "no prediction";
 1211                        var databaseText = databasePrediction != null ? $"{databasePrediction.HomeGoals}:{databasePredic
 212
 1213                        _console.MarkupLine($"[red]✗ {match.HomeTeam} vs {match.AwayTeam}:[/]");
 1214                        _console.MarkupLine($"  [yellow]Kicktipp:[/] {kicktippText}");
 1215                        _console.MarkupLine($"  [yellow]Database:[/] {databaseText}");
 216
 1217                        if (isOutdated)
 218                        {
 1219                            _console.MarkupLine($"  [yellow]Status:[/] Outdated (context updated after prediction)");
 220                        }
 221                    }
 222                }
 1223            }
 1224            catch (Exception ex)
 225            {
 1226                hasDiscrepancies = true;
 1227                _logger.LogError(ex, "Error verifying prediction for {Match}", $"{match.HomeTeam} vs {match.AwayTeam}");
 228
 1229                if (settings.Agent)
 230                {
 1231                    _console.MarkupLine($"[red]✗ {match.HomeTeam} vs {match.AwayTeam}[/] [dim](error)[/]");
 232                }
 233                else
 234                {
 1235                    _console.MarkupLine($"[red]✗ {match.HomeTeam} vs {match.AwayTeam}:[/] Error during verification");
 236                }
 1237            }
 1238        }
 239
 240        // Step 3: Display summary
 1241        _console.WriteLine();
 1242        _console.MarkupLine("[bold]Verification Summary:[/]");
 1243        _console.MarkupLine($"  Total matches: {totalMatches}");
 1244        _console.MarkupLine($"  Matches with Kicktipp predictions: {matchesWithPlacedPredictions}");
 1245        _console.MarkupLine($"  Matches with database predictions: {matchesWithDatabasePredictions}");
 1246        _console.MarkupLine($"  Matching predictions: {matchingPredictions}");
 247
 248        // Check for init-matchday mode first
 1249        if (settings.InitMatchday && matchesWithDatabasePredictions == 0)
 250        {
 1251            _console.MarkupLine("[yellow]  Init matchday detected - no database predictions exist[/]");
 1252            _console.MarkupLine("[red]Returning error to trigger initial prediction workflow[/]");
 1253            return true; // Return error to trigger workflow
 254        }
 255
 1256        if (hasDiscrepancies)
 257        {
 1258            _console.MarkupLine($"[red]  Discrepancies found: {totalMatches - matchingPredictions}[/]");
 1259            _console.MarkupLine("[red]Verification failed - predictions do not match[/]");
 260        }
 261        else
 262        {
 1263            _console.MarkupLine("[green]  All predictions match - verification successful[/]");
 264        }
 265
 1266        return hasDiscrepancies;
 1267    }
 268
 269    private static bool ComparePredictions(BetPrediction? kicktippPrediction, Prediction? databasePrediction)
 270    {
 271        // Both null - match
 1272        if (kicktippPrediction == null && databasePrediction == null)
 273        {
 1274            return true;
 275        }
 276
 277        // One null, other not - mismatch
 1278        if (kicktippPrediction == null || databasePrediction == null)
 279        {
 1280            return false;
 281        }
 282
 283        // Both have values - compare
 1284        return kicktippPrediction.HomeGoals == databasePrediction.HomeGoals &&
 1285               kicktippPrediction.AwayGoals == databasePrediction.AwayGoals;
 286    }
 287
 288    private async Task<bool> CheckPredictionOutdated(IPredictionRepository predictionRepository, IContextRepository cont
 289    {
 290        try
 291        {
 292            // Get prediction metadata with context document names and timestamps
 293            // For cancelled matches, use team-names-only lookup to handle startsAt inconsistencies
 294            PredictionMetadata? predictionMetadata;
 1295            if (match.IsCancelled)
 296            {
 1297                predictionMetadata = await predictionRepository.GetCancelledMatchPredictionMetadataAsync(
 1298                    match.HomeTeam, match.AwayTeam, modelConfig, communityContext);
 299            }
 300            else
 301            {
 1302                predictionMetadata = await predictionRepository.GetPredictionMetadataAsync(match, modelConfig, community
 303            }
 304
 1305            if (predictionMetadata == null || !predictionMetadata.ContextDocumentNames.Any())
 306            {
 307                // If no context documents were used, prediction can't be outdated based on context changes
 1308                return false;
 309            }
 310
 1311            if (verbose)
 312            {
 1313                _console.MarkupLine($"[dim]  Checking {predictionMetadata.ContextDocumentNames.Count} context documents 
 314            }
 315
 316            // Check if any context document has been updated after the prediction was created
 1317            foreach (var documentName in predictionMetadata.ContextDocumentNames)
 318            {
 319                // Strip any display suffix (e.g., " (kpi-context)") from the context document name
 320                // to get the actual document name stored in the repository
 1321                var actualDocumentName = StripDisplaySuffix(documentName);
 322
 1323                var standingsDocumentName = MatchContextDocumentCatalog.GetStandingsDocumentName(competition);
 1324                if (actualDocumentName.Equals(standingsDocumentName, StringComparison.OrdinalIgnoreCase))
 325                {
 1326                    if (verbose)
 327                    {
 1328                        _console.MarkupLine($"[dim]  Skipping outdated check for '{actualDocumentName}' (excluded from c
 329                    }
 1330                    continue;
 331                }
 332
 1333                var latestContextDocument = await contextRepository.GetLatestContextDocumentAsync(actualDocumentName, co
 334
 1335                if (latestContextDocument != null && latestContextDocument.CreatedAt > predictionMetadata.CreatedAt)
 336                {
 1337                    var predictionTimeContextDocument = await contextRepository.GetContextDocumentByTimestampAsync(
 1338                        actualDocumentName,
 1339                        predictionMetadata.CreatedAt,
 1340                        communityContext);
 341
 1342                    if (predictionTimeContextDocument != null &&
 1343                        string.Equals(
 1344                            predictionTimeContextDocument.Content,
 1345                            latestContextDocument.Content,
 1346                            StringComparison.Ordinal))
 347                    {
 1348                        if (verbose)
 349                        {
 1350                            _console.MarkupLine(
 1351                                $"[dim]  Context document '{actualDocumentName}' has newer versions after the prediction
 352                        }
 353
 1354                        continue;
 355                    }
 356
 1357                    if (verbose)
 358                    {
 1359                        _console.MarkupLine($"[dim]  Context document '{actualDocumentName}' (stored as '{documentName}'
 360                    }
 1361                    return true; // Prediction is outdated
 362                }
 1363                else if (verbose && latestContextDocument == null)
 364                {
 1365                    _console.MarkupLine($"[yellow]  Warning: Context document '{actualDocumentName}' not found in reposi
 366                }
 1367            }
 368
 1369            return false; // Prediction is up-to-date
 370        }
 1371        catch (Exception ex)
 372        {
 373            // Log error but don't fail verification due to outdated check issues
 1374            if (verbose)
 375            {
 1376                _console.MarkupLine($"[yellow]  Warning: Failed to check outdated status: {ex.Message}[/]");
 377            }
 1378            return false;
 379        }
 1380    }
 381
 382    /// <summary>
 383    /// Strips display suffixes like " (kpi-context)" from context document names
 384    /// to get the actual document name used in the repository.
 385    /// </summary>
 386    /// <param name="displayName">The display name that may contain a suffix</param>
 387    /// <returns>The actual document name without any display suffix</returns>
 388    private static string StripDisplaySuffix(string displayName)
 389    {
 390        // Look for patterns like " (some-text)" at the end and remove them
 1391        var lastParenIndex = displayName.LastIndexOf(" (");
 1392        if (lastParenIndex > 0 && displayName.EndsWith(")"))
 393        {
 1394            return displayName.Substring(0, lastParenIndex);
 395        }
 1396        return displayName;
 397    }
 398}