< Summary

Information
Class: Orchestrator.Commands.Operations.Matchday.MatchdayCommand
Assembly: Orchestrator
File(s): /home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Operations/Matchday/MatchdayCommand.cs
Line coverage
97%
Covered lines: 357
Uncovered lines: 8
Coverable lines: 365
Total lines: 775
Line coverage: 97.8%
Branch coverage
94%
Covered branches: 193
Total branches: 204
Branch coverage: 94.6%
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%3030100%
ExecuteMatchdayWorkflow()94%10010099.38%
GetMatchContextDocumentsAsync()90%202091.84%
GetHybridContextAsync()100%1010100%
GetTeamAbbreviation(...)90%1010100%
CheckPredictionOutdated()95.83%242496%
WriteJustificationIfNeeded(...)83.33%66100%
StripDisplaySuffix(...)100%44100%

File(s)

/home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Operations/Matchday/MatchdayCommand.cs

#LineLine coverage
 1using Microsoft.Extensions.Logging;
 2using Spectre.Console.Cli;
 3using Spectre.Console;
 4using KicktippIntegration;
 5using OpenAiIntegration;
 6using ContextProviders.Kicktipp;
 7using EHonda.KicktippAi.Core;
 8using Orchestrator.Commands.Shared;
 9using Orchestrator.Infrastructure.Factories;
 10
 11namespace Orchestrator.Commands.Operations.Matchday;
 12
 13public class MatchdayCommand : AsyncCommand<BaseSettings>
 14{
 15    private readonly IAnsiConsole _console;
 16    private readonly IFirebaseServiceFactory _firebaseServiceFactory;
 17    private readonly IKicktippClientFactory _kicktippClientFactory;
 18    private readonly IOpenAiServiceFactory _openAiServiceFactory;
 19    private readonly IContextProviderFactory _contextProviderFactory;
 20    private readonly ILogger<MatchdayCommand> _logger;
 21
 122    public MatchdayCommand(
 123        IAnsiConsole console,
 124        IFirebaseServiceFactory firebaseServiceFactory,
 125        IKicktippClientFactory kicktippClientFactory,
 126        IOpenAiServiceFactory openAiServiceFactory,
 127        IContextProviderFactory contextProviderFactory,
 128        ILogger<MatchdayCommand> logger)
 29    {
 130        _console = console;
 131        _firebaseServiceFactory = firebaseServiceFactory;
 132        _kicktippClientFactory = kicktippClientFactory;
 133        _openAiServiceFactory = openAiServiceFactory;
 134        _contextProviderFactory = contextProviderFactory;
 135        _logger = logger;
 136    }
 37
 38    public override async Task<int> ExecuteAsync(CommandContext context, BaseSettings settings)
 39    {
 40
 41        try
 42        {
 143            _console.MarkupLine($"[green]Matchday command initialized with model:[/] [yellow]{settings.Model}[/]");
 44
 145            if (settings.Verbose)
 46            {
 147                _console.MarkupLine("[dim]Verbose mode enabled[/]");
 48            }
 49
 150            if (settings.OverrideKicktipp)
 51            {
 152                _console.MarkupLine("[yellow]Override mode enabled - will override existing Kicktipp predictions[/]");
 53            }
 54
 155            if (settings.OverrideDatabase)
 56            {
 157                _console.MarkupLine("[yellow]Override database mode enabled - will override existing database prediction
 58            }
 59
 160            if (settings.Agent)
 61            {
 162                _console.MarkupLine("[blue]Agent mode enabled - prediction details will be hidden[/]");
 63            }
 64
 165            if (settings.DryRun)
 66            {
 167                _console.MarkupLine("[magenta]Dry run mode enabled - no changes will be made to database or Kicktipp[/]"
 68            }
 69
 170            if (!string.IsNullOrEmpty(settings.EstimatedCostsModel))
 71            {
 172                _console.MarkupLine($"[cyan]Estimated costs will be calculated for model:[/] [yellow]{settings.Estimated
 73            }
 74
 175            if (settings.WithJustification)
 76            {
 177                if (settings.Agent)
 78                {
 179                    _console.MarkupLine("[red]Error:[/] --with-justification cannot be used with --agent");
 180                    return 1;
 81                }
 82
 183                _console.MarkupLine("[green]Justification output enabled - model reasoning will be captured[/]");
 84            }
 85
 86            // Validate reprediction settings
 187            if (settings.OverrideDatabase && settings.IsRepredictMode)
 88            {
 189                _console.MarkupLine($"[red]Error:[/] --override-database cannot be used with reprediction flags (--repre
 190                return 1;
 91            }
 92
 193            if (settings.MaxRepredictions.HasValue && settings.MaxRepredictions.Value < 0)
 94            {
 195                _console.MarkupLine($"[red]Error:[/] --max-repredictions must be 0 or greater");
 196                return 1;
 97            }
 98
 199            if (settings.IsRepredictMode)
 100            {
 1101                var maxValue = settings.MaxRepredictions ?? int.MaxValue;
 1102                _console.MarkupLine($"[yellow]Reprediction mode enabled - max repredictions: {(settings.MaxRepredictions
 103            }
 104
 105            // Execute the matchday workflow
 1106            await ExecuteMatchdayWorkflow(settings);
 107
 1108            return 0;
 109        }
 1110        catch (Exception ex)
 111        {
 1112            _logger.LogError(ex, "Error executing matchday command");
 1113            _console.MarkupLine($"[red]Error:[/] {ex.Message}");
 1114            return 1;
 115        }
 1116    }
 117
 118    private async Task ExecuteMatchdayWorkflow(BaseSettings settings)
 119    {
 120        // Create services using factories
 1121        var kicktippClient = _kicktippClientFactory.CreateClient();
 1122        var predictionService = _openAiServiceFactory.CreatePredictionService(settings.Model);
 123
 124        // Create context provider using factory
 1125        string communityContext = settings.CommunityContext ?? settings.Community;
 1126        var contextProvider = _contextProviderFactory.CreateKicktippContextProvider(
 1127            kicktippClient, settings.Community, communityContext);
 128
 1129        var tokenUsageTracker = _openAiServiceFactory.GetTokenUsageTracker();
 130
 131        // Log the prompt paths being used
 1132        if (settings.Verbose)
 133        {
 1134            _console.MarkupLine($"[dim]Match prompt:[/] [blue]{predictionService.GetMatchPromptPath(settings.WithJustifi
 135        }
 136
 137        // Create repositories
 1138        var predictionRepository = _firebaseServiceFactory.CreatePredictionRepository();
 1139        var contextRepository = _firebaseServiceFactory.CreateContextRepository();
 1140        var databaseEnabled = true;
 141
 142        // Reset token usage tracker for this workflow
 1143        tokenUsageTracker.Reset();
 144
 1145        _console.MarkupLine($"[blue]Using community:[/] [yellow]{settings.Community}[/]");
 1146        _console.MarkupLine($"[blue]Using community context:[/] [yellow]{communityContext}[/]");
 1147        _console.MarkupLine("[blue]Getting current matchday matches...[/]");
 148
 149        // Step 1: Get current matchday via GetMatchesWithHistoryAsync
 1150        var matchesWithHistory = await kicktippClient.GetMatchesWithHistoryAsync(settings.Community);
 151
 1152        if (!matchesWithHistory.Any())
 153        {
 1154            _console.MarkupLine("[yellow]No matches found for current matchday[/]");
 1155            return;
 156        }
 157
 1158        _console.MarkupLine($"[green]Found {matchesWithHistory.Count} matches for current matchday[/]");
 159
 1160        if (databaseEnabled)
 161        {
 1162            _console.MarkupLine("[blue]Database enabled - checking for existing predictions...[/]");
 163        }
 164
 1165        var predictions = new Dictionary<Match, BetPrediction>();
 166
 167        // Step 2: For each match, check database first, then predict if needed
 1168        foreach (var matchWithHistory in matchesWithHistory)
 169        {
 1170            var match = matchWithHistory.Match;
 171
 172            // Log warning for cancelled matches - they have inherited times which may affect database operations
 1173            if (match.IsCancelled)
 174            {
 1175                _console.MarkupLine($"[yellow]  ⚠ {match.HomeTeam} vs {match.AwayTeam} is cancelled (Abgesagt). " +
 1176                    $"Processing with inherited time - prediction may need re-evaluation when rescheduled.[/]");
 177            }
 178
 1179            _console.MarkupLine($"[cyan]Processing:[/] {match.HomeTeam} vs {match.AwayTeam}{(match.IsCancelled ? " [yell
 180
 181            try
 182            {
 1183                Prediction? prediction = null;
 1184                bool fromDatabase = false;
 1185                bool shouldPredict = false;
 186
 187                // Check if we have an existing prediction in the database
 1188                if (databaseEnabled && !settings.OverrideDatabase && !settings.IsRepredictMode)
 189                {
 1190                    prediction = await predictionRepository!.GetPredictionAsync(match, settings.Model, communityContext)
 1191                    if (prediction != null)
 192                    {
 1193                        fromDatabase = true;
 1194                        if (settings.Agent)
 195                        {
 1196                            _console.MarkupLine($"[green]  ✓ Found existing prediction[/] [dim](from database)[/]");
 197                        }
 198                        else
 199                        {
 1200                            _console.MarkupLine($"[green]  ✓ Found existing prediction:[/] {prediction.HomeGoals}:{predi
 1201                            WriteJustificationIfNeeded(prediction, settings.WithJustification, fromDatabase: true);
 202                        }
 203                    }
 204                }
 205
 206                // Handle reprediction logic
 1207                if (settings.IsRepredictMode && databaseEnabled)
 208                {
 1209                    var currentRepredictionIndex = await predictionRepository!.GetMatchRepredictionIndexAsync(match, set
 210
 1211                    if (currentRepredictionIndex == -1)
 212                    {
 213                        // No prediction exists yet - create first prediction
 1214                        shouldPredict = true;
 1215                        _console.MarkupLine($"[yellow]  → No existing prediction found, creating first prediction...[/]"
 216                    }
 217                    else
 218                    {
 219                        // Check if we can create another reprediction
 1220                        var maxAllowed = settings.MaxRepredictions ?? int.MaxValue;
 1221                        var nextIndex = currentRepredictionIndex + 1;
 222
 1223                        if (nextIndex <= maxAllowed)
 224                        {
 225                            // Before repredicting, check if the current prediction is actually outdated
 1226                            var isOutdated = await CheckPredictionOutdated(predictionRepository!, contextRepository, mat
 227
 1228                            if (isOutdated)
 229                            {
 1230                                shouldPredict = true;
 1231                                _console.MarkupLine($"[yellow]  → Creating reprediction {nextIndex} (current: {currentRe
 232                            }
 233                            else
 234                            {
 1235                                _console.MarkupLine($"[green]  ✓ Skipped reprediction - current prediction is up-to-date
 236
 237                                // Get the latest prediction for display purposes
 1238                                prediction = await predictionRepository!.GetPredictionAsync(match, settings.Model, commu
 1239                                if (prediction != null)
 240                                {
 1241                                    fromDatabase = true;
 1242                                    if (!settings.Agent)
 243                                    {
 1244                                        _console.MarkupLine($"[green]  ✓ Latest prediction:[/] {prediction.HomeGoals}:{p
 1245                                        WriteJustificationIfNeeded(prediction, settings.WithJustification, fromDatabase:
 246                                    }
 247                                }
 248                            }
 249                        }
 250                        else
 251                        {
 1252                            _console.MarkupLine($"[yellow]  ✗ Skipped - already at max repredictions ({currentRepredicti
 253
 254                            // Get the latest prediction for display purposes
 1255                            prediction = await predictionRepository!.GetPredictionAsync(match, settings.Model, community
 1256                            if (prediction != null)
 257                            {
 1258                                fromDatabase = true;
 1259                                if (!settings.Agent)
 260                                {
 1261                                    _console.MarkupLine($"[green]  ✓ Latest prediction:[/] {prediction.HomeGoals}:{predi
 1262                                    WriteJustificationIfNeeded(prediction, settings.WithJustification, fromDatabase: tru
 263                                }
 264                            }
 265                        }
 266                    }
 267                }
 268
 269                // If no existing prediction (normal mode) or we need to predict (reprediction mode), generate a new one
 1270                if (prediction == null || shouldPredict)
 271                {
 1272                    _console.MarkupLine($"[yellow]  → Generating new prediction...[/]");
 273
 274                    // Step 3: Get context using hybrid approach (database first, fallback to on-demand)
 1275                    var contextDocuments = await GetHybridContextAsync(
 1276                        contextRepository,
 1277                        contextProvider,
 1278                        match.HomeTeam,
 1279                        match.AwayTeam,
 1280                        communityContext,
 1281                        settings.Verbose);
 282
 1283                    if (settings.Verbose)
 284                    {
 1285                        _console.MarkupLine($"[dim]    Using {contextDocuments.Count} context documents[/]");
 286                    }
 287
 288                    // Show context documents if requested
 1289                    if (settings.ShowContextDocuments)
 290                    {
 1291                        _console.MarkupLine($"[cyan]    Context documents for {match.HomeTeam} vs {match.AwayTeam}:[/]")
 1292                        foreach (var doc in contextDocuments)
 293                        {
 1294                            _console.MarkupLine($"[dim]    📄 {doc.Name}[/]");
 295
 296                            // Show first few lines and total line count for readability
 1297                            var lines = doc.Content.Split('\n');
 1298                            var previewLines = lines.Take(10).ToArray();
 1299                            var hasMore = lines.Length > 10;
 300
 1301                            foreach (var line in previewLines)
 302                            {
 1303                                _console.MarkupLine($"[grey]      {line.EscapeMarkup()}[/]");
 304                            }
 305
 1306                            if (hasMore)
 307                            {
 0308                                _console.MarkupLine($"[dim]      ... ({lines.Length - 10} more lines) ...[/]");
 309                            }
 310
 1311                            _console.MarkupLine($"[dim]      (Total: {lines.Length} lines, {doc.Content.Length} characte
 1312                            _console.WriteLine();
 313                        }
 314                    }
 315
 316                    // Predict the match
 1317                    prediction = await predictionService.PredictMatchAsync(match, contextDocuments, settings.WithJustifi
 318
 1319                    if (prediction != null)
 320                    {
 1321                        if (settings.Agent)
 322                        {
 1323                            _console.MarkupLine($"[green]  ✓ Generated prediction[/]");
 324                        }
 325                        else
 326                        {
 1327                            _console.MarkupLine($"[green]  ✓ Generated prediction:[/] {prediction.HomeGoals}:{prediction
 1328                            WriteJustificationIfNeeded(prediction, settings.WithJustification);
 329                        }
 330
 331                        // Save to database immediately if enabled
 1332                        if (databaseEnabled && !settings.DryRun)
 333                        {
 334                            try
 335                            {
 336                                // Get token usage and cost information
 1337                                var cost = (double)tokenUsageTracker.GetLastCost(); // Get the cost for this individual 
 338                                // Use the new GetLastUsageJson method to get full JSON
 1339                                var tokenUsageJson = tokenUsageTracker.GetLastUsageJson() ?? "{}";
 340
 1341                                if (settings.IsRepredictMode)
 342                                {
 343                                    // Save as reprediction with specific index
 1344                                    var currentIndex = await predictionRepository!.GetMatchRepredictionIndexAsync(match,
 1345                                    var nextIndex = currentIndex == -1 ? 0 : currentIndex + 1;
 346
 1347                                    await predictionRepository!.SaveRepredictionAsync(
 1348                                        match,
 1349                                        prediction,
 1350                                        settings.Model,
 1351                                        tokenUsageJson,
 1352                                        cost,
 1353                                        communityContext,
 0354                                        contextDocuments.Select(d => d.Name),
 1355                                        nextIndex);
 356
 1357                                    if (settings.Verbose)
 358                                    {
 1359                                        _console.MarkupLine($"[dim]    ✓ Saved as reprediction {nextIndex} to database[/
 360                                    }
 361                                }
 362                                else
 363                                {
 364                                    // Save normally (override or new prediction)
 1365                                    await predictionRepository!.SavePredictionAsync(
 1366                                        match,
 1367                                        prediction,
 1368                                        settings.Model,
 1369                                        tokenUsageJson,
 1370                                        cost,
 1371                                        communityContext,
 0372                                        contextDocuments.Select(d => d.Name),
 1373                                        overrideCreatedAt: settings.OverrideDatabase);
 374
 1375                                    if (settings.Verbose)
 376                                    {
 1377                                        _console.MarkupLine($"[dim]    ✓ Saved to database[/]");
 378                                    }
 379                                }
 1380                            }
 1381                            catch (Exception ex)
 382                            {
 1383                                _logger.LogError(ex, "Failed to save prediction for match {Match}", match);
 1384                                _console.MarkupLine($"[red]    ✗ Failed to save to database: {ex.Message}[/]");
 1385                            }
 386                        }
 1387                        else if (databaseEnabled && settings.DryRun && settings.Verbose)
 388                        {
 1389                            _console.MarkupLine($"[dim]    (Dry run - skipped database save)[/]");
 390                        }
 391
 392                        // Show individual match token usage in verbose mode
 1393                        if (settings.Verbose)
 394                        {
 1395                            var matchUsage = !string.IsNullOrEmpty(settings.EstimatedCostsModel)
 1396                                ? tokenUsageTracker.GetLastUsageCompactSummaryWithEstimatedCosts(settings.EstimatedCosts
 1397                                : tokenUsageTracker.GetLastUsageCompactSummary();
 1398                            _console.MarkupLine($"[dim]    Token usage: {matchUsage}[/]");
 399                        }
 400                    }
 401                    else
 402                    {
 1403                        _console.MarkupLine($"[red]  ✗ Failed to generate prediction[/]");
 1404                        continue;
 405                    }
 1406                }
 407
 408                // Convert to BetPrediction for Kicktipp
 1409                var betPrediction = new BetPrediction(prediction.HomeGoals, prediction.AwayGoals);
 1410                predictions[match] = betPrediction;
 411
 1412                if (!fromDatabase && settings.Verbose)
 413                {
 1414                    _console.MarkupLine($"[dim]    Already saved to database[/]");
 415                }
 1416            }
 1417            catch (Exception ex)
 418            {
 1419                _logger.LogError(ex, "Error processing match {Match}", match);
 1420                _console.MarkupLine($"[red]  ✗ Error processing match: {ex.Message}[/]");
 1421            }
 1422        }
 423
 1424        if (!predictions.Any())
 425        {
 1426            _console.MarkupLine("[yellow]No predictions available, nothing to place[/]");
 1427            return;
 428        }
 429
 430        // Step 4: Place all predictions using PlaceBetsAsync
 1431        _console.MarkupLine($"[blue]Placing {predictions.Count} predictions to Kicktipp...[/]");
 432
 1433        if (settings.DryRun)
 434        {
 1435            _console.MarkupLine($"[magenta]✓ Dry run mode - would have placed {predictions.Count} predictions (no actual
 436        }
 437        else
 438        {
 1439            var success = await kicktippClient.PlaceBetsAsync(settings.Community, predictions, overrideBets: settings.Ov
 440
 1441            if (success)
 442            {
 1443                _console.MarkupLine($"[green]✓ Successfully placed all {predictions.Count} predictions![/]");
 444            }
 445            else
 446            {
 1447                _console.MarkupLine("[red]✗ Failed to place some or all predictions[/]");
 448            }
 449        }
 450
 451        // Display token usage summary
 1452        var summary = !string.IsNullOrEmpty(settings.EstimatedCostsModel)
 1453            ? tokenUsageTracker.GetCompactSummaryWithEstimatedCosts(settings.EstimatedCostsModel)
 1454            : tokenUsageTracker.GetCompactSummary();
 1455        _console.MarkupLine($"[dim]Token usage (uncached/cached/reasoning/output/$cost): {summary}[/]");
 1456    }
 457
 458    /// <summary>
 459    /// Retrieves all available context documents from the database for the given community context.
 460    /// </summary>
 461    private async Task<Dictionary<string, DocumentContext>> GetMatchContextDocumentsAsync(
 462        IContextRepository contextRepository,
 463        string homeTeam,
 464        string awayTeam,
 465        string communityContext,
 466        bool verbose = false)
 467    {
 1468        var contextDocuments = new Dictionary<string, DocumentContext>();
 1469        var homeAbbreviation = GetTeamAbbreviation(homeTeam);
 1470        var awayAbbreviation = GetTeamAbbreviation(awayTeam);
 471
 472        // Define the 7 specific document names needed for a match (required core set)
 1473        var requiredDocuments = new[]
 1474        {
 1475            "bundesliga-standings.csv",
 1476            $"community-rules-{communityContext}.md",
 1477            $"recent-history-{homeAbbreviation}.csv",
 1478            $"recent-history-{awayAbbreviation}.csv",
 1479            $"home-history-{homeAbbreviation}.csv",
 1480            $"away-history-{awayAbbreviation}.csv",
 1481            $"head-to-head-{homeAbbreviation}-vs-{awayAbbreviation}.csv"
 1482        };
 483
 484        // Optional transfers documents (do not affect required count). Naming: <abbr>-transfers.csv
 1485        var optionalDocuments = new[]
 1486        {
 1487            $"{homeAbbreviation}-transfers.csv",
 1488            $"{awayAbbreviation}-transfers.csv"
 1489        };
 490
 1491        if (verbose)
 492        {
 1493            _console.MarkupLine($"[dim]    Looking for {requiredDocuments.Length} specific context documents in database
 494        }
 495
 496        try
 497        {
 498            // Retrieve each required document
 1499            foreach (var documentName in requiredDocuments)
 500            {
 1501                var contextDoc = await contextRepository.GetLatestContextDocumentAsync(documentName, communityContext);
 1502                if (contextDoc != null)
 503                {
 1504                    contextDocuments[documentName] = new DocumentContext(contextDoc.DocumentName, contextDoc.Content);
 505
 1506                    if (verbose)
 507                    {
 1508                        _console.MarkupLine($"[dim]      ✓ Retrieved {documentName} (version {contextDoc.Version})[/]");
 509                    }
 510                }
 511                else
 512                {
 1513                    if (verbose)
 514                    {
 1515                        _console.MarkupLine($"[dim]      ✗ Missing {documentName}[/]");
 516                    }
 517                }
 1518            }
 519
 520            // Retrieve optional transfers documents (best-effort)
 1521            foreach (var documentName in optionalDocuments)
 522            {
 523                try
 524                {
 1525                    var contextDoc = await contextRepository.GetLatestContextDocumentAsync(documentName, communityContex
 1526                    if (contextDoc != null)
 527                    {
 528                        // Display name suffix to distinguish optional docs in prediction metadata (helps debug)
 1529                        contextDocuments[documentName] = new DocumentContext(contextDoc.DocumentName, contextDoc.Content
 1530                        if (verbose)
 531                        {
 1532                            _console.MarkupLine($"[dim]      ✓ Retrieved optional {documentName} (version {contextDoc.Ve
 533                        }
 534                    }
 1535                    else if (verbose)
 536                    {
 1537                        _console.MarkupLine($"[dim]      · Missing optional {documentName}[/]");
 538                    }
 1539                }
 0540                catch (Exception optEx)
 541                {
 0542                    if (verbose)
 543                    {
 0544                        _console.MarkupLine($"[dim]      · Failed optional {documentName}: {optEx.Message}[/]");
 545                    }
 0546                }
 1547            }
 1548        }
 1549        catch (Exception ex)
 550        {
 1551            _console.MarkupLine($"[red]    Warning: Failed to retrieve context from database: {ex.Message}[/]");
 1552        }
 553
 1554        return contextDocuments;
 1555    }
 556
 557    /// <summary>
 558    /// Gets context documents using database first, falling back to on-demand context provider if needed.
 559    /// </summary>
 560    private async Task<List<DocumentContext>> GetHybridContextAsync(
 561        IContextRepository contextRepository,
 562        IKicktippContextProvider contextProvider,
 563        string homeTeam,
 564        string awayTeam,
 565        string communityContext,
 566        bool verbose = false)
 567    {
 1568        var contextDocuments = new List<DocumentContext>();
 569        // Step 1: Retrieve any database documents (required + optional)
 1570        var databaseContexts = await GetMatchContextDocumentsAsync(
 1571            contextRepository,
 1572            homeTeam,
 1573            awayTeam,
 1574            communityContext,
 1575            verbose);
 576
 577        // Reconstruct required document names (must match logic in GetMatchContextDocumentsAsync)
 1578        var homeAbbreviation = GetTeamAbbreviation(homeTeam);
 1579        var awayAbbreviation = GetTeamAbbreviation(awayTeam);
 1580        var requiredDocuments = new[]
 1581        {
 1582            "bundesliga-standings.csv",
 1583            $"community-rules-{communityContext}.md",
 1584            $"recent-history-{homeAbbreviation}.csv",
 1585            $"recent-history-{awayAbbreviation}.csv",
 1586            $"home-history-{homeAbbreviation}.csv",
 1587            $"away-history-{awayAbbreviation}.csv",
 1588            $"head-to-head-{homeAbbreviation}-vs-{awayAbbreviation}.csv"
 1589        };
 590
 1591        int requiredPresent = requiredDocuments.Count(d => databaseContexts.ContainsKey(d));
 1592        int requiredTotal = requiredDocuments.Length;
 593
 1594        if (requiredPresent == requiredTotal)
 595        {
 596            // All required docs present; include every database doc (required + optional)
 1597            if (verbose)
 598            {
 1599                _console.MarkupLine($"[green]    Using {databaseContexts.Count} context documents from database (all req
 600            }
 1601            contextDocuments.AddRange(databaseContexts.Values);
 602        }
 603        else
 604        {
 605            // Fallback: use on-demand provider but still include any database docs we already have (including optional 
 1606            _console.MarkupLine($"[yellow]    Warning: Only found {requiredPresent}/{requiredTotal} required context doc
 607
 608            // Start with database docs
 1609            contextDocuments.AddRange(databaseContexts.Values);
 610
 611            // Add on-demand docs, skipping duplicates by name
 1612            var existingNames = new HashSet<string>(contextDocuments.Select(c => c.Name), StringComparer.OrdinalIgnoreCa
 1613            await foreach (var context in contextProvider.GetMatchContextAsync(homeTeam, awayTeam))
 614            {
 1615                if (existingNames.Add(context.Name))
 616                {
 1617                    contextDocuments.Add(context);
 618                }
 619            }
 620
 1621            if (verbose)
 622            {
 1623                _console.MarkupLine($"[yellow]    Using {contextDocuments.Count} merged context documents (database + on
 624            }
 1625        }
 626
 1627        return contextDocuments;
 1628    }
 629
 630    /// <summary>
 631    /// Gets a team abbreviation for file naming.
 632    /// </summary>
 633    private static string GetTeamAbbreviation(string teamName)
 634    {
 635        // Current season team abbreviations (2025-26 Bundesliga participants)
 1636        var abbreviations = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
 1637        {
 1638            { "1. FC Heidenheim 1846", "fch" },
 1639            { "1. FC Köln", "fck" },
 1640            { "1. FC Union Berlin", "fcu" },
 1641            { "1899 Hoffenheim", "tsg" },
 1642            { "Bayer 04 Leverkusen", "b04" },
 1643            { "Bor. Mönchengladbach", "bmg" },
 1644            { "Borussia Dortmund", "bvb" },
 1645            { "Eintracht Frankfurt", "sge" },
 1646            { "FC Augsburg", "fca" },
 1647            { "FC Bayern München", "fcb" },
 1648            { "FC St. Pauli", "fcs" },
 1649            { "FSV Mainz 05", "m05" },
 1650            { "Hamburger SV", "hsv" },
 1651            { "RB Leipzig", "rbl" },
 1652            { "SC Freiburg", "scf" },
 1653            { "VfB Stuttgart", "vfb" },
 1654            { "VfL Wolfsburg", "wob" },
 1655            { "Werder Bremen", "svw" }
 1656        };
 657
 1658        if (abbreviations.TryGetValue(teamName, out var abbreviation))
 659        {
 1660            return abbreviation;
 661        }
 662
 663        // Fallback: create abbreviation from team name
 1664        var words = teamName.Split(' ', StringSplitOptions.RemoveEmptyEntries);
 1665        var abbr = new System.Text.StringBuilder();
 666
 1667        foreach (var word in words.Take(3)) // Take up to 3 words
 668        {
 1669            if (word.Length > 0 && char.IsLetter(word[0]))
 670            {
 1671                abbr.Append(char.ToLowerInvariant(word[0]));
 672            }
 673        }
 674
 1675        return abbr.Length > 0 ? abbr.ToString() : "unknown";
 676    }
 677
 678    private async Task<bool> CheckPredictionOutdated(IPredictionRepository predictionRepository, IContextRepository cont
 679    {
 680        try
 681        {
 682            // Get prediction metadata with context document names and timestamps
 1683            var predictionMetadata = await predictionRepository.GetPredictionMetadataAsync(match, model, communityContex
 684
 1685            if (predictionMetadata == null || !predictionMetadata.ContextDocumentNames.Any())
 686            {
 687                // If no context documents were used, prediction can't be outdated based on context changes
 1688                return false;
 689            }
 690
 1691            if (verbose)
 692            {
 1693                _console.MarkupLine($"[dim]  Checking {predictionMetadata.ContextDocumentNames.Count} context documents 
 694            }
 695
 696            // Check if any context document has been updated after the prediction was created
 1697            foreach (var documentName in predictionMetadata.ContextDocumentNames)
 698            {
 699                // Strip any display suffix (e.g., " (kpi-context)") from the context document name
 700                // to get the actual document name stored in the repository
 1701                var actualDocumentName = StripDisplaySuffix(documentName);
 702
 703                // Skip bundesliga-standings.csv from outdated check to reduce unnecessary repredictions
 1704                if (actualDocumentName.Equals("bundesliga-standings.csv", StringComparison.OrdinalIgnoreCase))
 705                {
 1706                    if (verbose)
 707                    {
 1708                        _console.MarkupLine($"[dim]  Skipping outdated check for '{actualDocumentName}' (excluded from c
 709                    }
 1710                    continue;
 711                }
 712
 1713                var latestContextDocument = await contextRepository.GetLatestContextDocumentAsync(actualDocumentName, co
 714
 1715                if (latestContextDocument != null && latestContextDocument.CreatedAt > predictionMetadata.CreatedAt)
 716                {
 1717                    if (verbose)
 718                    {
 1719                        _console.MarkupLine($"[dim]  Context document '{actualDocumentName}' (stored as '{documentName}'
 720                    }
 1721                    return true; // Prediction is outdated
 722                }
 1723                else if (verbose && latestContextDocument == null)
 724                {
 1725                    _console.MarkupLine($"[yellow]  Warning: Context document '{actualDocumentName}' not found in reposi
 726                }
 1727            }
 728
 1729            return false; // Prediction is up-to-date
 730        }
 1731        catch (Exception ex)
 732        {
 733            // Log error but don't fail verification due to outdated check issues
 1734            if (verbose)
 735            {
 0736                _console.MarkupLine($"[yellow]  Warning: Failed to check outdated status: {ex.Message}[/]");
 737            }
 1738            return false;
 739        }
 1740    }
 741
 742    private void WriteJustificationIfNeeded(Prediction? prediction, bool includeJustification, bool fromDatabase = false
 743    {
 1744        if (!includeJustification || prediction == null)
 745        {
 1746            return;
 747        }
 748
 1749        var sourceLabel = fromDatabase ? "stored prediction" : "model response";
 750
 1751        var justificationWriter = new JustificationConsoleWriter(_console);
 1752        justificationWriter.WriteJustification(
 1753            prediction.Justification,
 1754            "[dim]    ↳ Justification:[/]",
 1755            "        ",
 1756            $"[yellow]    ↳ No justification available for this {sourceLabel}[/]");
 1757    }
 758
 759    /// <summary>
 760    /// Strips display suffixes like " (kpi-context)" from context document names
 761    /// to get the actual document name used in the repository.
 762    /// </summary>
 763    /// <param name="displayName">The display name that may contain a suffix</param>
 764    /// <returns>The actual document name without any display suffix</returns>
 765    private static string StripDisplaySuffix(string displayName)
 766    {
 767        // Look for patterns like " (some-text)" at the end and remove them
 1768        var lastParenIndex = displayName.LastIndexOf(" (");
 1769        if (lastParenIndex > 0 && displayName.EndsWith(")"))
 770        {
 1771            return displayName.Substring(0, lastParenIndex);
 772        }
 1773        return displayName;
 774    }
 775}