< 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: 156
Uncovered lines: 0
Coverable lines: 156
Total lines: 373
Line coverage: 100%
Branch coverage
98%
Covered branches: 114
Total branches: 116
Branch coverage: 98.2%
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%2626100%
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.Infrastructure.Factories;
 7
 8namespace Orchestrator.Commands.Operations.Verify;
 9
 10public class VerifyMatchdayCommand : AsyncCommand<VerifySettings>
 11{
 12    private readonly IAnsiConsole _console;
 13    private readonly IFirebaseServiceFactory _firebaseServiceFactory;
 14    private readonly IKicktippClientFactory _kicktippClientFactory;
 15    private readonly ILogger<VerifyMatchdayCommand> _logger;
 16
 117    public VerifyMatchdayCommand(
 118        IAnsiConsole console,
 119        IFirebaseServiceFactory firebaseServiceFactory,
 120        IKicktippClientFactory kicktippClientFactory,
 121        ILogger<VerifyMatchdayCommand> 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 matchday 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 matchday 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 matchday 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 context repository for outdated checks (may be null if Firebase is not configured)
 183        var contextRepository = _firebaseServiceFactory.CreateContextRepository();
 184        if (settings.CheckOutdated && contextRepository == null)
 85        {
 186            _console.MarkupLine("[red]Error: Database not configured. Cannot check outdated predictions without database
 187            _console.MarkupLine("[yellow]Hint: Set FIREBASE_PROJECT_ID and FIREBASE_SERVICE_ACCOUNT_JSON environment var
 188            return true; // Consider this a failure
 89        }
 90
 91        // Determine community context (use explicit setting or fall back to community name)
 192        string communityContext = settings.CommunityContext ?? settings.Community;
 93
 194        _console.MarkupLine($"[blue]Using community:[/] [yellow]{settings.Community}[/]");
 195        _console.MarkupLine($"[blue]Using community context:[/] [yellow]{communityContext}[/]");
 196        _console.MarkupLine("[blue]Getting placed predictions from Kicktipp...[/]");
 97
 98        // Step 1: Get placed predictions from Kicktipp
 199        var placedPredictions = await kicktippClient.GetPlacedPredictionsAsync(settings.Community);
 100
 1101        if (!placedPredictions.Any())
 102        {
 1103            _console.MarkupLine("[yellow]No matches found on Kicktipp[/]");
 1104            return false;
 105        }
 106
 1107        _console.MarkupLine($"[green]Found {placedPredictions.Count} matches on Kicktipp[/]");
 108
 1109        _console.MarkupLine("[blue]Retrieving predictions from database...[/]");
 110
 1111        var hasDiscrepancies = false;
 1112        var totalMatches = 0;
 1113        var matchesWithPlacedPredictions = 0;
 1114        var matchesWithDatabasePredictions = 0;
 1115        var matchingPredictions = 0;
 116
 117        // Step 2: For each match, compare with database predictions
 1118        foreach (var (match, kicktippPrediction) in placedPredictions)
 119        {
 1120            totalMatches++;
 121
 122            try
 123            {
 124                Prediction? databasePrediction;
 125
 126                // For cancelled matches, use team-names-only lookup to handle startsAt inconsistencies
 127                // See IPredictionRepository.cs for detailed documentation on this edge case
 1128                if (match.IsCancelled)
 129                {
 1130                    if (settings.Verbose)
 131                    {
 1132                        _console.MarkupLine($"[dim]  Looking up (cancelled match, team-names-only): {match.HomeTeam} vs 
 133                    }
 1134                    databasePrediction = await predictionRepository.GetCancelledMatchPredictionAsync(
 1135                        match.HomeTeam, match.AwayTeam, settings.Model, communityContext);
 136                }
 137                else
 138                {
 1139                    if (settings.Verbose)
 140                    {
 1141                        _console.MarkupLine($"[dim]  Looking up: {match.HomeTeam} vs {match.AwayTeam} at {match.StartsAt
 142                    }
 1143                    databasePrediction = await predictionRepository.GetPredictionAsync(match, settings.Model, communityC
 144                }
 145
 1146                if (kicktippPrediction != null)
 147                {
 1148                    matchesWithPlacedPredictions++;
 149                }
 150
 1151                if (databasePrediction != null)
 152                {
 1153                    matchesWithDatabasePredictions++;
 1154                    if (settings.Verbose && !settings.Agent)
 155                    {
 1156                        _console.MarkupLine($"[dim]  Found database prediction: {databasePrediction.HomeGoals}:{database
 157                    }
 158                }
 1159                else if (settings.Verbose && !settings.Agent)
 160                {
 1161                    _console.MarkupLine($"[dim]  No database prediction found[/]");
 162                }
 163
 164                // Check if prediction is outdated (if enabled and context repository is available)
 1165                var isOutdated = false;
 1166                if (settings.CheckOutdated && contextRepository != null && databasePrediction != null)
 167                {
 1168                    isOutdated = await CheckPredictionOutdated(predictionRepository, contextRepository, match, settings.
 169                }
 170
 171                // Compare predictions
 1172                var isMatchingPrediction = ComparePredictions(kicktippPrediction, databasePrediction);
 173
 174                // Consider prediction invalid if it's outdated or mismatched
 1175                var isValidPrediction = isMatchingPrediction && !isOutdated;
 176
 1177                if (isValidPrediction)
 178                {
 1179                    matchingPredictions++;
 180
 1181                    if (settings.Verbose)
 182                    {
 1183                        if (settings.Agent)
 184                        {
 1185                            _console.MarkupLine($"[green]✓ {match.HomeTeam} vs {match.AwayTeam}[/] [dim](valid)[/]");
 186                        }
 187                        else
 188                        {
 1189                            var predictionText = kicktippPrediction?.ToString() ?? "no prediction";
 1190                            _console.MarkupLine($"[green]✓ {match.HomeTeam} vs {match.AwayTeam}:[/] {predictionText} [di
 191                        }
 192                    }
 193                }
 194                else
 195                {
 1196                    hasDiscrepancies = true;
 197
 1198                    if (settings.Agent)
 199                    {
 1200                        var reason = isOutdated ? "outdated" : "mismatch";
 1201                        _console.MarkupLine($"[red]✗ {match.HomeTeam} vs {match.AwayTeam}[/] [dim]({reason})[/]");
 202                    }
 203                    else
 204                    {
 1205                        var kicktippText = kicktippPrediction?.ToString() ?? "no prediction";
 1206                        var databaseText = databasePrediction != null ? $"{databasePrediction.HomeGoals}:{databasePredic
 207
 1208                        _console.MarkupLine($"[red]✗ {match.HomeTeam} vs {match.AwayTeam}:[/]");
 1209                        _console.MarkupLine($"  [yellow]Kicktipp:[/] {kicktippText}");
 1210                        _console.MarkupLine($"  [yellow]Database:[/] {databaseText}");
 211
 1212                        if (isOutdated)
 213                        {
 1214                            _console.MarkupLine($"  [yellow]Status:[/] Outdated (context updated after prediction)");
 215                        }
 216                    }
 217                }
 1218            }
 1219            catch (Exception ex)
 220            {
 1221                hasDiscrepancies = true;
 1222                _logger.LogError(ex, "Error verifying prediction for {Match}", $"{match.HomeTeam} vs {match.AwayTeam}");
 223
 1224                if (settings.Agent)
 225                {
 1226                    _console.MarkupLine($"[red]✗ {match.HomeTeam} vs {match.AwayTeam}[/] [dim](error)[/]");
 227                }
 228                else
 229                {
 1230                    _console.MarkupLine($"[red]✗ {match.HomeTeam} vs {match.AwayTeam}:[/] Error during verification");
 231                }
 1232            }
 1233        }
 234
 235        // Step 3: Display summary
 1236        _console.WriteLine();
 1237        _console.MarkupLine("[bold]Verification Summary:[/]");
 1238        _console.MarkupLine($"  Total matches: {totalMatches}");
 1239        _console.MarkupLine($"  Matches with Kicktipp predictions: {matchesWithPlacedPredictions}");
 1240        _console.MarkupLine($"  Matches with database predictions: {matchesWithDatabasePredictions}");
 1241        _console.MarkupLine($"  Matching predictions: {matchingPredictions}");
 242
 243        // Check for init-matchday mode first
 1244        if (settings.InitMatchday && matchesWithDatabasePredictions == 0)
 245        {
 1246            _console.MarkupLine("[yellow]  Init matchday detected - no database predictions exist[/]");
 1247            _console.MarkupLine("[red]Returning error to trigger initial prediction workflow[/]");
 1248            return true; // Return error to trigger workflow
 249        }
 250
 1251        if (hasDiscrepancies)
 252        {
 1253            _console.MarkupLine($"[red]  Discrepancies found: {totalMatches - matchingPredictions}[/]");
 1254            _console.MarkupLine("[red]Verification failed - predictions do not match[/]");
 255        }
 256        else
 257        {
 1258            _console.MarkupLine("[green]  All predictions match - verification successful[/]");
 259        }
 260
 1261        return hasDiscrepancies;
 1262    }
 263
 264    private static bool ComparePredictions(BetPrediction? kicktippPrediction, Prediction? databasePrediction)
 265    {
 266        // Both null - match
 1267        if (kicktippPrediction == null && databasePrediction == null)
 268        {
 1269            return true;
 270        }
 271
 272        // One null, other not - mismatch
 1273        if (kicktippPrediction == null || databasePrediction == null)
 274        {
 1275            return false;
 276        }
 277
 278        // Both have values - compare
 1279        return kicktippPrediction.HomeGoals == databasePrediction.HomeGoals &&
 1280               kicktippPrediction.AwayGoals == databasePrediction.AwayGoals;
 281    }
 282
 283    private async Task<bool> CheckPredictionOutdated(IPredictionRepository predictionRepository, IContextRepository cont
 284    {
 285        try
 286        {
 287            // Get prediction metadata with context document names and timestamps
 288            // For cancelled matches, use team-names-only lookup to handle startsAt inconsistencies
 289            PredictionMetadata? predictionMetadata;
 1290            if (match.IsCancelled)
 291            {
 1292                predictionMetadata = await predictionRepository.GetCancelledMatchPredictionMetadataAsync(
 1293                    match.HomeTeam, match.AwayTeam, model, communityContext);
 294            }
 295            else
 296            {
 1297                predictionMetadata = await predictionRepository.GetPredictionMetadataAsync(match, model, communityContex
 298            }
 299
 1300            if (predictionMetadata == null || !predictionMetadata.ContextDocumentNames.Any())
 301            {
 302                // If no context documents were used, prediction can't be outdated based on context changes
 1303                return false;
 304            }
 305
 1306            if (verbose)
 307            {
 1308                _console.MarkupLine($"[dim]  Checking {predictionMetadata.ContextDocumentNames.Count} context documents 
 309            }
 310
 311            // Check if any context document has been updated after the prediction was created
 1312            foreach (var documentName in predictionMetadata.ContextDocumentNames)
 313            {
 314                // Strip any display suffix (e.g., " (kpi-context)") from the context document name
 315                // to get the actual document name stored in the repository
 1316                var actualDocumentName = StripDisplaySuffix(documentName);
 317
 318                // Skip bundesliga-standings.csv from outdated check to reduce unnecessary repredictions
 1319                if (actualDocumentName.Equals("bundesliga-standings.csv", StringComparison.OrdinalIgnoreCase))
 320                {
 1321                    if (verbose)
 322                    {
 1323                        _console.MarkupLine($"[dim]  Skipping outdated check for '{actualDocumentName}' (excluded from c
 324                    }
 1325                    continue;
 326                }
 327
 1328                var latestContextDocument = await contextRepository.GetLatestContextDocumentAsync(actualDocumentName, co
 329
 1330                if (latestContextDocument != null && latestContextDocument.CreatedAt > predictionMetadata.CreatedAt)
 331                {
 1332                    if (verbose)
 333                    {
 1334                        _console.MarkupLine($"[dim]  Context document '{actualDocumentName}' (stored as '{documentName}'
 335                    }
 1336                    return true; // Prediction is outdated
 337                }
 1338                else if (verbose && latestContextDocument == null)
 339                {
 1340                    _console.MarkupLine($"[yellow]  Warning: Context document '{actualDocumentName}' not found in reposi
 341                }
 1342            }
 343
 1344            return false; // Prediction is up-to-date
 345        }
 1346        catch (Exception ex)
 347        {
 348            // Log error but don't fail verification due to outdated check issues
 1349            if (verbose)
 350            {
 1351                _console.MarkupLine($"[yellow]  Warning: Failed to check outdated status: {ex.Message}[/]");
 352            }
 1353            return false;
 354        }
 1355    }
 356
 357    /// <summary>
 358    /// Strips display suffixes like " (kpi-context)" from context document names
 359    /// to get the actual document name used in the repository.
 360    /// </summary>
 361    /// <param name="displayName">The display name that may contain a suffix</param>
 362    /// <returns>The actual document name without any display suffix</returns>
 363    private static string StripDisplaySuffix(string displayName)
 364    {
 365        // Look for patterns like " (some-text)" at the end and remove them
 1366        var lastParenIndex = displayName.LastIndexOf(" (");
 1367        if (lastParenIndex > 0 && displayName.EndsWith(")"))
 368        {
 1369            return displayName.Substring(0, lastParenIndex);
 370        }
 1371        return displayName;
 372    }
 373}