< 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
99%
Covered lines: 445
Uncovered lines: 2
Coverable lines: 447
Total lines: 933
Line coverage: 99.5%
Branch coverage
96%
Covered branches: 243
Total branches: 251
Branch coverage: 96.8%
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%11100%
ExecuteWithSettingsAsync()100%3030100%
.cctor()100%11100%
ExecuteMatchdayWorkflow()95.1%143143100%
GetMatchContextDocumentsAsync()100%2020100%
GetHybridContextAsync()100%1212100%
EnsureWorldCupRequiredContextPresent(...)100%44100%
CheckPredictionOutdated()100%3232100%
WriteJustificationIfNeeded(...)83.33%66100%
StripDisplaySuffix(...)100%44100%

File(s)

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

#LineLine coverage
 1using System.Text.Json;
 2using Microsoft.Extensions.Logging;
 3using Spectre.Console.Cli;
 4using Spectre.Console;
 5using KicktippIntegration;
 6using OpenAiIntegration;
 7using ContextProviders.Kicktipp;
 8using EHonda.KicktippAi.Core;
 9using Orchestrator.Commands.Shared;
 10using Orchestrator.Infrastructure;
 11using Orchestrator.Infrastructure.Factories;
 12using Orchestrator.Infrastructure.Langfuse;
 13
 14namespace Orchestrator.Commands.Operations.Matchday;
 15
 16public class MatchdayCommand : AsyncCommand<BaseSettings>
 17{
 18    private readonly IAnsiConsole _console;
 19    private readonly IFirebaseServiceFactory _firebaseServiceFactory;
 20    private readonly IKicktippClientFactory _kicktippClientFactory;
 21    private readonly IOpenAiServiceFactory _openAiServiceFactory;
 22    private readonly IContextProviderFactory _contextProviderFactory;
 23    private readonly ILogger<MatchdayCommand> _logger;
 24    private readonly ILangfusePublicApiClient? _langfuseClient;
 25
 126    public MatchdayCommand(
 127        IAnsiConsole console,
 128        IFirebaseServiceFactory firebaseServiceFactory,
 129        IKicktippClientFactory kicktippClientFactory,
 130        IOpenAiServiceFactory openAiServiceFactory,
 131        IContextProviderFactory contextProviderFactory,
 132        ILogger<MatchdayCommand> logger,
 133        ILangfusePublicApiClient? langfuseClient = null)
 34    {
 135        _console = console;
 136        _firebaseServiceFactory = firebaseServiceFactory;
 137        _kicktippClientFactory = kicktippClientFactory;
 138        _openAiServiceFactory = openAiServiceFactory;
 139        _contextProviderFactory = contextProviderFactory;
 140        _logger = logger;
 141        _langfuseClient = langfuseClient;
 142    }
 43
 44    protected override async Task<int> ExecuteAsync(CommandContext context, BaseSettings settings, CancellationToken can
 45    {
 146        return await ExecuteWithSettingsAsync(settings, cancellationToken);
 147    }
 48
 49    internal async Task<int> ExecuteWithSettingsAsync(BaseSettings settings, CancellationToken cancellationToken = defau
 50    {
 51
 52        try
 53        {
 154            var initialModel = string.IsNullOrWhiteSpace(settings.Model) ? "(competition default)" : settings.Model;
 155            _console.MarkupLine($"[green]Matchday command initialized with model:[/] [yellow]{initialModel}[/]");
 56
 157            if (settings.Verbose)
 58            {
 159                _console.MarkupLine("[dim]Verbose mode enabled[/]");
 60            }
 61
 162            if (settings.OverrideKicktipp)
 63            {
 164                _console.MarkupLine("[yellow]Override mode enabled - will override existing Kicktipp predictions[/]");
 65            }
 66
 167            if (settings.OverrideDatabase)
 68            {
 169                _console.MarkupLine("[yellow]Override database mode enabled - will override existing database prediction
 70            }
 71
 172            if (settings.Agent)
 73            {
 174                _console.MarkupLine("[blue]Agent mode enabled - prediction details will be hidden[/]");
 75            }
 76
 177            if (settings.DryRun)
 78            {
 179                _console.MarkupLine("[magenta]Dry run mode enabled - no changes will be made to database or Kicktipp[/]"
 80            }
 81
 182            if (!string.IsNullOrEmpty(settings.EstimatedCostsModel))
 83            {
 184                _console.MarkupLine($"[cyan]Estimated costs will be calculated for model:[/] [yellow]{settings.Estimated
 85            }
 86
 187            if (settings.WithJustification)
 88            {
 189                if (settings.Agent)
 90                {
 191                    _console.MarkupLine("[red]Error:[/] --with-justification cannot be used with --agent");
 192                    return 1;
 93                }
 94
 195                _console.MarkupLine("[green]Justification output enabled - model reasoning will be captured[/]");
 96            }
 97
 98            // Validate reprediction settings
 199            if (settings.OverrideDatabase && settings.IsRepredictMode)
 100            {
 1101                _console.MarkupLine($"[red]Error:[/] --override-database cannot be used with reprediction flags (--repre
 1102                return 1;
 103            }
 104
 1105            if (settings.MaxRepredictions.HasValue && settings.MaxRepredictions.Value < 0)
 106            {
 1107                _console.MarkupLine($"[red]Error:[/] --max-repredictions must be 0 or greater");
 1108                return 1;
 109            }
 110
 1111            if (settings.IsRepredictMode)
 112            {
 1113                var maxValue = settings.MaxRepredictions ?? int.MaxValue;
 1114                _console.MarkupLine($"[yellow]Reprediction mode enabled - max repredictions: {(settings.MaxRepredictions
 115            }
 116
 117            // Execute the matchday workflow
 1118            await ExecuteMatchdayWorkflow(settings);
 119
 1120            return 0;
 121        }
 1122        catch (Exception ex)
 123        {
 1124            _logger.LogError(ex, "Error executing matchday command");
 1125            _console.MarkupLine($"[red]Error:[/] {ex.Message}");
 1126            return 1;
 127        }
 1128    }
 129
 130    /// <summary>
 131    /// Communities that have production workflows invoking the matchday command.
 132    /// Update this set when adding or removing community matchday workflows in .github/workflows/.
 133    /// See .github/workflows/AGENTS.md for details.
 134    /// </summary>
 1135    private static readonly HashSet<string> ProductionCommunities = new(StringComparer.OrdinalIgnoreCase)
 1136    {
 1137        "schadensfresse",
 1138        "pes-squad",
 1139        "ehonda-ai-arena",
 1140        "rabetrabauken2026"
 1141    };
 142
 143    private async Task ExecuteMatchdayWorkflow(BaseSettings settings)
 144    {
 145        // Start root OTel activity for Langfuse trace
 1146        using var activity = Telemetry.Source.StartActivity("matchday");
 147
 148        // Set Langfuse environment based on community
 1149        var environment = ProductionCommunities.Contains(settings.Community) ? "production" : "development";
 1150        LangfuseActivityPropagation.SetEnvironment(activity, environment);
 151
 1152        string communityContext = settings.CommunityContext ?? settings.Community;
 1153        var competition = CompetitionResolver.ResolveCompetition(settings.Competition, settings.Community, communityCont
 1154        var modelConfig = PredictionServiceCommandSupport.CreateModelConfig(settings.Model, settings.ReasoningEffort);
 1155        var model = modelConfig.Model;
 1156        var repositoryCompetition = CompetitionResolver.ToRepositoryCompetitionArgument(competition);
 157
 1158        if (settings.WithJustification && PredictionServiceCommandSupport.UsesLangfusePromptSource(
 1159                competition,
 1160                settings.Community,
 1161                communityContext,
 1162                settings.PromptSource,
 1163                bonusPrompt: false))
 164        {
 1165            throw new NotSupportedException(
 1166                "WM 2026 hosted match prompts with justification are not supported yet. Use local prompts or omit --with
 167        }
 168
 169        // Create services using factories
 1170        var kicktippClient = _kicktippClientFactory.CreateClient();
 1171        var predictionService = PredictionServiceCommandSupport.CreatePredictionService(
 1172            _openAiServiceFactory,
 1173            _langfuseClient,
 1174            _console,
 1175            model,
 1176            competition,
 1177            settings.Community,
 1178            communityContext,
 1179            settings.PromptSource,
 1180            settings.LangfusePromptName,
 1181            settings.LangfusePromptLabel,
 1182            settings.LangfusePromptVersion,
 1183            modelConfig.ReasoningEffort,
 1184            settings.MaxOutputTokenCount,
 1185            bonusPrompt: false);
 186
 187        // Create context provider using factory
 1188        var contextProvider = _contextProviderFactory.CreateKicktippContextProvider(
 1189            kicktippClient, settings.Community, communityContext, repositoryCompetition);
 190
 1191        var tokenUsageTracker = _openAiServiceFactory.GetTokenUsageTracker();
 192
 193        // Log the prompt paths being used
 1194        if (settings.Verbose)
 195        {
 1196            _console.MarkupLine($"[dim]Match prompt:[/] [blue]{predictionService.GetMatchPromptPath(settings.WithJustifi
 197        }
 198
 199        // Create repositories
 1200        var predictionRepository = _firebaseServiceFactory.CreatePredictionRepository(repositoryCompetition);
 1201        var contextRepository = _firebaseServiceFactory.CreateContextRepository(repositoryCompetition);
 1202        var databaseEnabled = true;
 203
 204        // Reset token usage tracker for this workflow
 1205        tokenUsageTracker.Reset();
 206
 1207        _console.MarkupLine($"[blue]Using community:[/] [yellow]{settings.Community}[/]");
 1208        _console.MarkupLine($"[blue]Using community context:[/] [yellow]{communityContext}[/]");
 1209        _console.MarkupLine($"[blue]Using competition:[/] [yellow]{competition}[/]");
 1210        _console.MarkupLine("[blue]Getting current matchday matches...[/]");
 211
 212        // Step 1: Get current matchday via GetMatchesWithHistoryAsync
 1213        var matchesWithHistory = await kicktippClient.GetMatchesWithHistoryAsync(settings.Community);
 214
 1215        if (!matchesWithHistory.Any())
 216        {
 1217            _console.MarkupLine("[yellow]No matches found for current matchday[/]");
 1218            return;
 219        }
 220
 221        // Set Langfuse trace-level attributes now that we know the matchday
 1222        var matchday = matchesWithHistory.First().Match.Matchday;
 1223        var sessionId = $"matchday-{matchday}-{settings.Community}";
 1224        var traceTags = new[] { settings.Community, model, competition };
 1225        LangfuseActivityPropagation.SetSessionId(activity, sessionId);
 1226        LangfuseActivityPropagation.SetTraceTags(activity, traceTags);
 1227        LangfuseActivityPropagation.SetTraceMetadata(activity, "community", settings.Community);
 1228        LangfuseActivityPropagation.SetTraceMetadata(activity, "communityContext", communityContext);
 1229        LangfuseActivityPropagation.SetTraceMetadata(activity, "competition", competition);
 1230        LangfuseActivityPropagation.SetTraceMetadata(activity, "matchday", matchday.ToString());
 1231        LangfuseActivityPropagation.SetTraceMetadata(activity, "model", model);
 1232        if (modelConfig.ReasoningEffort is not null)
 233        {
 1234            LangfuseActivityPropagation.SetTraceMetadata(activity, "reasoningEffort", modelConfig.ReasoningEffort);
 235        }
 1236        LangfuseActivityPropagation.SetTraceMetadata(activity, "homeTeams", PredictionTelemetryMetadata.BuildDelimitedFi
 1237        LangfuseActivityPropagation.SetTraceMetadata(activity, "awayTeams", PredictionTelemetryMetadata.BuildDelimitedFi
 1238        LangfuseActivityPropagation.SetTraceMetadata(activity, "teams", PredictionTelemetryMetadata.BuildDelimitedFilter
 1239        LangfuseActivityPropagation.SetTraceMetadata(activity, "repredictMode", settings.IsRepredictMode ? "true" : "fal
 240
 241        // Set trace input
 1242        var traceInput = new
 1243        {
 1244            community = settings.Community,
 1245            matchday,
 1246            model,
 1247            competition,
 1248            matches = matchesWithHistory.Select(m => $"{m.Match.HomeTeam} vs {m.Match.AwayTeam}").ToArray()
 1249        };
 1250        activity?.SetTag("langfuse.trace.input", JsonSerializer.Serialize(traceInput));
 251
 1252        _console.MarkupLine($"[green]Found {matchesWithHistory.Count} matches for current matchday[/]");
 253
 1254        if (databaseEnabled)
 255        {
 1256            _console.MarkupLine("[blue]Database enabled - checking for existing predictions...[/]");
 257        }
 258
 1259        var predictions = new Dictionary<Match, BetPrediction>();
 1260        var traceRepredictionIndices = new HashSet<string>(StringComparer.Ordinal);
 261
 262        // Step 2: For each match, check database first, then predict if needed
 1263        foreach (var matchWithHistory in matchesWithHistory)
 264        {
 1265            var match = matchWithHistory.Match;
 266
 267            // Log warning for cancelled matches - they have inherited times which may affect database operations
 1268            if (match.IsCancelled)
 269            {
 1270                _console.MarkupLine($"[yellow]  ⚠ {match.HomeTeam} vs {match.AwayTeam} is cancelled (Abgesagt). " +
 1271                    $"Processing with inherited time - prediction may need re-evaluation when rescheduled.[/]");
 272            }
 273
 1274            _console.MarkupLine($"[cyan]Processing:[/] {match.HomeTeam} vs {match.AwayTeam}{(match.IsCancelled ? " [yell
 275
 276            try
 277            {
 1278                Prediction? prediction = null;
 1279                bool fromDatabase = false;
 1280                bool shouldPredict = false;
 1281                int? predictionRepredictionIndex = settings.IsRepredictMode ? null : 0;
 282
 283                // Check if we have an existing prediction in the database
 1284                if (databaseEnabled && !settings.OverrideDatabase && !settings.IsRepredictMode)
 285                {
 286                    // For cancelled matches, use team-names-only lookup to handle startsAt inconsistencies
 287                    // See IPredictionRepository.cs for detailed documentation on this edge case
 1288                    if (match.IsCancelled)
 289                    {
 1290                        prediction = await predictionRepository!.GetCancelledMatchPredictionAsync(
 1291                            match.HomeTeam, match.AwayTeam, modelConfig, communityContext);
 292                    }
 293                    else
 294                    {
 1295                        prediction = await predictionRepository!.GetPredictionAsync(match, modelConfig, communityContext
 296                    }
 297
 1298                    if (prediction != null)
 299                    {
 1300                        fromDatabase = true;
 1301                        if (settings.Agent)
 302                        {
 1303                            _console.MarkupLine($"[green]  ✓ Found existing prediction[/] [dim](from database)[/]");
 304                        }
 305                        else
 306                        {
 1307                            _console.MarkupLine($"[green]  ✓ Found existing prediction:[/] {prediction.HomeGoals}:{predi
 1308                            WriteJustificationIfNeeded(prediction, settings.WithJustification, fromDatabase: true);
 309                        }
 310                    }
 311                }
 312
 313                // Handle reprediction logic
 1314                if (settings.IsRepredictMode && databaseEnabled)
 315                {
 316                    // For cancelled matches, use team-names-only lookup to handle startsAt inconsistencies
 317                    int currentRepredictionIndex;
 1318                    if (match.IsCancelled)
 319                    {
 1320                        currentRepredictionIndex = await predictionRepository!.GetCancelledMatchRepredictionIndexAsync(
 1321                            match.HomeTeam, match.AwayTeam, modelConfig, communityContext);
 322                    }
 323                    else
 324                    {
 1325                        currentRepredictionIndex = await predictionRepository!.GetMatchRepredictionIndexAsync(match, mod
 326                    }
 327
 1328                    if (currentRepredictionIndex == -1)
 329                    {
 330                        // No prediction exists yet - create first prediction
 1331                        shouldPredict = true;
 1332                        predictionRepredictionIndex = 0;
 1333                        _console.MarkupLine($"[yellow]  → No existing prediction found, creating first prediction...[/]"
 334                    }
 335                    else
 336                    {
 337                        // Check if we can create another reprediction
 1338                        var maxAllowed = settings.MaxRepredictions ?? int.MaxValue;
 1339                        var nextIndex = currentRepredictionIndex + 1;
 340
 1341                        if (nextIndex <= maxAllowed)
 342                        {
 343                            // Before repredicting, check if the current prediction is actually outdated
 1344                            var isOutdated = await CheckPredictionOutdated(predictionRepository!, contextRepository, mat
 345
 1346                            if (isOutdated)
 347                            {
 1348                                shouldPredict = true;
 1349                                predictionRepredictionIndex = nextIndex;
 1350                                _console.MarkupLine($"[yellow]  → Creating reprediction {nextIndex} (current: {currentRe
 351                            }
 352                            else
 353                            {
 1354                                traceRepredictionIndices.Add(currentRepredictionIndex.ToString());
 1355                                _console.MarkupLine($"[green]  ✓ Skipped reprediction - current prediction is up-to-date
 356
 357                                // Get the latest prediction for display purposes
 358                                // For cancelled matches, use team-names-only lookup
 1359                                if (match.IsCancelled)
 360                                {
 1361                                    prediction = await predictionRepository!.GetCancelledMatchPredictionAsync(
 1362                                        match.HomeTeam, match.AwayTeam, modelConfig, communityContext);
 363                                }
 364                                else
 365                                {
 1366                                    prediction = await predictionRepository!.GetPredictionAsync(match, modelConfig, comm
 367                                }
 368
 1369                                if (prediction != null)
 370                                {
 1371                                    fromDatabase = true;
 1372                                    if (!settings.Agent)
 373                                    {
 1374                                        _console.MarkupLine($"[green]  ✓ Latest prediction:[/] {prediction.HomeGoals}:{p
 1375                                        WriteJustificationIfNeeded(prediction, settings.WithJustification, fromDatabase:
 376                                    }
 377                                }
 378                            }
 379                        }
 380                        else
 381                        {
 1382                            traceRepredictionIndices.Add(currentRepredictionIndex.ToString());
 1383                            _console.MarkupLine($"[yellow]  ✗ Skipped - already at max repredictions ({currentRepredicti
 384
 385                            // Get the latest prediction for display purposes
 386                            // For cancelled matches, use team-names-only lookup
 1387                            if (match.IsCancelled)
 388                            {
 1389                                prediction = await predictionRepository!.GetCancelledMatchPredictionAsync(
 1390                                    match.HomeTeam, match.AwayTeam, modelConfig, communityContext);
 391                            }
 392                            else
 393                            {
 1394                                prediction = await predictionRepository!.GetPredictionAsync(match, modelConfig, communit
 395                            }
 396
 1397                            if (prediction != null)
 398                            {
 1399                                fromDatabase = true;
 1400                                if (!settings.Agent)
 401                                {
 1402                                    _console.MarkupLine($"[green]  ✓ Latest prediction:[/] {prediction.HomeGoals}:{predi
 1403                                    WriteJustificationIfNeeded(prediction, settings.WithJustification, fromDatabase: tru
 404                                }
 405                            }
 406                        }
 407                    }
 408                }
 409
 410                // If no existing prediction (normal mode) or we need to predict (reprediction mode), generate a new one
 1411                if (prediction == null || shouldPredict)
 412                {
 1413                    _console.MarkupLine($"[yellow]  → Generating new prediction...[/]");
 414
 415                    // Step 3: Get context using hybrid approach (database first, fallback to on-demand)
 1416                    var contextDocuments = await GetHybridContextAsync(
 1417                        contextRepository,
 1418                        contextProvider,
 1419                        match.HomeTeam,
 1420                        match.AwayTeam,
 1421                        communityContext,
 1422                        competition,
 1423                        settings.Verbose);
 424
 1425                    if (settings.Verbose)
 426                    {
 1427                        _console.MarkupLine($"[dim]    Using {contextDocuments.Count} context documents[/]");
 428                    }
 429
 430                    // Show context documents if requested
 1431                    if (settings.ShowContextDocuments)
 432                    {
 1433                        _console.MarkupLine($"[cyan]    Context documents for {match.HomeTeam} vs {match.AwayTeam}:[/]")
 1434                        foreach (var doc in contextDocuments)
 435                        {
 1436                            _console.MarkupLine($"[dim]    📄 {doc.Name}[/]");
 437
 438                            // Show first few lines and total line count for readability
 1439                            var lines = doc.Content.Split('\n');
 1440                            var previewLines = lines.Take(10).ToArray();
 1441                            var hasMore = lines.Length > 10;
 442
 1443                            foreach (var line in previewLines)
 444                            {
 1445                                _console.MarkupLine($"[grey]      {line.EscapeMarkup()}[/]");
 446                            }
 447
 1448                            if (hasMore)
 449                            {
 1450                                _console.MarkupLine($"[dim]      ... ({lines.Length - 10} more lines) ...[/]");
 451                            }
 452
 1453                            _console.MarkupLine($"[dim]      (Total: {lines.Length} lines, {doc.Content.Length} characte
 1454                            _console.WriteLine();
 455                        }
 456                    }
 457
 1458                    var telemetryMetadata = new PredictionTelemetryMetadata(
 1459                        HomeTeam: match.HomeTeam,
 1460                        AwayTeam: match.AwayTeam,
 1461                        RepredictionIndex: predictionRepredictionIndex);
 462
 463                    // Predict the match
 1464                    prediction = await predictionService.PredictMatchAsync(match, contextDocuments, settings.WithJustifi
 465
 1466                    if (prediction != null)
 467                    {
 1468                        if (predictionRepredictionIndex.HasValue)
 469                        {
 1470                            traceRepredictionIndices.Add(predictionRepredictionIndex.Value.ToString());
 471                        }
 472
 1473                        if (settings.Agent)
 474                        {
 1475                            _console.MarkupLine($"[green]  ✓ Generated prediction[/]");
 476                        }
 477                        else
 478                        {
 1479                            _console.MarkupLine($"[green]  ✓ Generated prediction:[/] {prediction.HomeGoals}:{prediction
 1480                            WriteJustificationIfNeeded(prediction, settings.WithJustification);
 481                        }
 482
 483                        // Save to database immediately if enabled
 1484                        if (databaseEnabled && !settings.DryRun)
 485                        {
 486                            try
 487                            {
 488                                // Get token usage and cost information
 1489                                var cost = (double)tokenUsageTracker.GetLastCost(); // Get the cost for this individual 
 490                                // Use the new GetLastUsageJson method to get full JSON
 1491                                var tokenUsageJson = tokenUsageTracker.GetLastUsageJson() ?? "{}";
 492
 1493                                if (settings.IsRepredictMode)
 494                                {
 495                                    // Save as reprediction with specific index
 496                                    // For cancelled matches, use team-names-only lookup for the current index
 497                                    int currentIndex;
 1498                                    if (match.IsCancelled)
 499                                    {
 1500                                        currentIndex = await predictionRepository!.GetCancelledMatchRepredictionIndexAsy
 1501                                            match.HomeTeam, match.AwayTeam, modelConfig, communityContext);
 502                                    }
 503                                    else
 504                                    {
 1505                                        currentIndex = await predictionRepository!.GetMatchRepredictionIndexAsync(match,
 506                                    }
 1507                                    var nextIndex = currentIndex == -1 ? 0 : currentIndex + 1;
 508
 1509                                    await predictionRepository!.SaveRepredictionAsync(
 1510                                        match,
 1511                                        prediction,
 1512                                        modelConfig,
 1513                                        tokenUsageJson,
 1514                                        cost,
 1515                                        communityContext,
 0516                                        contextDocuments.Select(d => d.Name),
 1517                                        nextIndex);
 518
 1519                                    if (settings.Verbose)
 520                                    {
 1521                                        _console.MarkupLine($"[dim]    ✓ Saved as reprediction {nextIndex} to database[/
 522                                    }
 523                                }
 524                                else
 525                                {
 526                                    // Save normally (override or new prediction)
 1527                                    await predictionRepository!.SavePredictionAsync(
 1528                                        match,
 1529                                        prediction,
 1530                                        modelConfig,
 1531                                        tokenUsageJson,
 1532                                        cost,
 1533                                        communityContext,
 0534                                        contextDocuments.Select(d => d.Name),
 1535                                        overrideCreatedAt: settings.OverrideDatabase);
 536
 1537                                    if (settings.Verbose)
 538                                    {
 1539                                        _console.MarkupLine($"[dim]    ✓ Saved to database[/]");
 540                                    }
 541                                }
 1542                            }
 1543                            catch (Exception ex)
 544                            {
 1545                                _logger.LogError(ex, "Failed to save prediction for match {Match}", match);
 1546                                _console.MarkupLine($"[red]    ✗ Failed to save to database: {ex.Message}[/]");
 1547                            }
 548                        }
 1549                        else if (databaseEnabled && settings.DryRun && settings.Verbose)
 550                        {
 1551                            _console.MarkupLine($"[dim]    (Dry run - skipped database save)[/]");
 552                        }
 553
 554                        // Show individual match token usage in verbose mode
 1555                        if (settings.Verbose)
 556                        {
 1557                            var matchUsage = !string.IsNullOrEmpty(settings.EstimatedCostsModel)
 1558                                ? tokenUsageTracker.GetLastUsageCompactSummaryWithEstimatedCosts(settings.EstimatedCosts
 1559                                : tokenUsageTracker.GetLastUsageCompactSummary();
 1560                            _console.MarkupLine($"[dim]    Token usage: {matchUsage}[/]");
 561                        }
 562                    }
 563                    else
 564                    {
 1565                        _console.MarkupLine($"[red]  ✗ Failed to generate prediction[/]");
 1566                        continue;
 567                    }
 1568                }
 569
 570                // Convert to BetPrediction for Kicktipp
 1571                var betPrediction = new BetPrediction(prediction.HomeGoals, prediction.AwayGoals);
 1572                predictions[match] = betPrediction;
 573
 1574                if (!fromDatabase && settings.Verbose)
 575                {
 1576                    _console.MarkupLine($"[dim]    Already saved to database[/]");
 577                }
 1578            }
 1579            catch (Exception ex)
 580            {
 1581                _logger.LogError(ex, "Error processing match {Match}", match);
 1582                _console.MarkupLine($"[red]  ✗ Error processing match: {ex.Message}[/]");
 1583            }
 1584        }
 585
 1586        if (traceRepredictionIndices.Count > 0)
 587        {
 1588            LangfuseActivityPropagation.SetTraceMetadata(activity, "repredictionIndices", PredictionTelemetryMetadata.Bu
 1589            LangfuseActivityPropagation.SetTraceMetadata(activity, "hasRepredictions", traceRepredictionIndices.Any(inde
 590        }
 591
 1592        if (!predictions.Any())
 593        {
 1594            _console.MarkupLine("[yellow]No predictions available, nothing to place[/]");
 1595            activity?.SetTag("langfuse.trace.output", JsonSerializer.Serialize(new { error = "No predictions available" 
 1596            return;
 597        }
 598
 599        // Set trace output with all predictions
 1600        var traceOutput = predictions.Select(p => new
 1601        {
 1602            match = $"{p.Key.HomeTeam} vs {p.Key.AwayTeam}",
 1603            prediction = $"{p.Value.HomeGoals}:{p.Value.AwayGoals}"
 1604        }).ToArray();
 1605        activity?.SetTag("langfuse.trace.output", JsonSerializer.Serialize(traceOutput));
 606
 607        // Step 4: Place all predictions using PlaceBetsAsync
 1608        _console.MarkupLine($"[blue]Placing {predictions.Count} predictions to Kicktipp...[/]");
 609
 1610        if (settings.DryRun)
 611        {
 1612            _console.MarkupLine($"[magenta]✓ Dry run mode - would have placed {predictions.Count} predictions (no actual
 613        }
 614        else
 615        {
 1616            var success = await kicktippClient.PlaceBetsAsync(settings.Community, predictions, overrideBets: settings.Ov
 617
 1618            if (success)
 619            {
 1620                _console.MarkupLine($"[green]✓ Successfully placed all {predictions.Count} predictions![/]");
 621            }
 622            else
 623            {
 1624                _console.MarkupLine("[red]✗ Failed to place some or all predictions[/]");
 625            }
 626        }
 627
 628        // Display token usage summary
 1629        var summary = !string.IsNullOrEmpty(settings.EstimatedCostsModel)
 1630            ? tokenUsageTracker.GetCompactSummaryWithEstimatedCosts(settings.EstimatedCostsModel)
 1631            : tokenUsageTracker.GetCompactSummary();
 1632        _console.MarkupLine($"[dim]Token usage (uncached/cached/reasoning/output/$cost): {summary}[/]");
 1633    }
 634
 635    /// <summary>
 636    /// Retrieves all available context documents from the database for the given community context.
 637    /// </summary>
 638    private async Task<Dictionary<string, DocumentContext>> GetMatchContextDocumentsAsync(
 639        IContextRepository contextRepository,
 640        string homeTeam,
 641        string awayTeam,
 642        string communityContext,
 643        string competition,
 644        bool verbose = false)
 645    {
 1646        var contextDocuments = new Dictionary<string, DocumentContext>();
 1647        var selection = MatchContextDocumentCatalog.ForMatch(homeTeam, awayTeam, communityContext, competition);
 648
 1649        if (verbose)
 650        {
 1651            _console.MarkupLine($"[dim]    Looking for {selection.RequiredDocumentNames.Count} specific context document
 652        }
 653
 654        try
 655        {
 656            // Retrieve each required document
 1657            foreach (var documentName in selection.RequiredDocumentNames)
 658            {
 1659                var contextDoc = await contextRepository.GetLatestContextDocumentAsync(documentName, communityContext);
 1660                if (contextDoc != null)
 661                {
 1662                    contextDocuments[documentName] = new DocumentContext(contextDoc.DocumentName, contextDoc.Content);
 663
 1664                    if (verbose)
 665                    {
 1666                        _console.MarkupLine($"[dim]      ✓ Retrieved {documentName} (version {contextDoc.Version})[/]");
 667                    }
 668                }
 669                else
 670                {
 1671                    if (verbose)
 672                    {
 1673                        _console.MarkupLine($"[dim]      ✗ Missing {documentName}[/]");
 674                    }
 675                }
 1676            }
 677
 678            // Retrieve optional transfers documents (best-effort)
 1679            foreach (var documentName in selection.OptionalDocumentNames)
 680            {
 681                try
 682                {
 1683                    var contextDoc = await contextRepository.GetLatestContextDocumentAsync(documentName, communityContex
 1684                    if (contextDoc != null)
 685                    {
 686                        // Display name suffix to distinguish optional docs in prediction metadata (helps debug)
 1687                        contextDocuments[documentName] = new DocumentContext(contextDoc.DocumentName, contextDoc.Content
 1688                        if (verbose)
 689                        {
 1690                            _console.MarkupLine($"[dim]      ✓ Retrieved optional {documentName} (version {contextDoc.Ve
 691                        }
 692                    }
 1693                    else if (verbose)
 694                    {
 1695                        _console.MarkupLine($"[dim]      · Missing optional {documentName}[/]");
 696                    }
 1697                }
 1698                catch (Exception optEx)
 699                {
 1700                    if (verbose)
 701                    {
 1702                        _console.MarkupLine($"[dim]      · Failed optional {documentName}: {optEx.Message}[/]");
 703                    }
 1704                }
 1705            }
 1706        }
 1707        catch (Exception ex)
 708        {
 1709            _console.MarkupLine($"[red]    Warning: Failed to retrieve context from database: {ex.Message}[/]");
 1710        }
 711
 1712        return contextDocuments;
 1713    }
 714
 715    /// <summary>
 716    /// Gets context documents using database first, falling back to on-demand context provider if needed.
 717    /// </summary>
 718    private async Task<List<DocumentContext>> GetHybridContextAsync(
 719        IContextRepository contextRepository,
 720        IKicktippContextProvider contextProvider,
 721        string homeTeam,
 722        string awayTeam,
 723        string communityContext,
 724        string competition,
 725        bool verbose = false)
 726    {
 1727        var contextDocuments = new List<DocumentContext>();
 728        // Step 1: Retrieve any database documents (required + optional)
 1729        var databaseContexts = await GetMatchContextDocumentsAsync(
 1730            contextRepository,
 1731            homeTeam,
 1732            awayTeam,
 1733            communityContext,
 1734            competition,
 1735            verbose);
 736
 1737        var requiredDocuments = MatchContextDocumentCatalog
 1738            .ForMatch(homeTeam, awayTeam, communityContext, competition)
 1739            .RequiredDocumentNames;
 740
 1741        int requiredPresent = requiredDocuments.Count(d => databaseContexts.ContainsKey(d));
 1742        int requiredTotal = requiredDocuments.Count;
 743
 1744        if (requiredPresent == requiredTotal)
 745        {
 746            // All required docs present; include every database doc (required + optional)
 1747            if (verbose)
 748            {
 1749                _console.MarkupLine($"[green]    Using {databaseContexts.Count} context documents from database (all req
 750            }
 1751            contextDocuments.AddRange(databaseContexts.Values);
 752        }
 753        else
 754        {
 755            // Fallback: use on-demand provider but still include any database docs we already have (including optional 
 1756            _console.MarkupLine($"[yellow]    Warning: Only found {requiredPresent}/{requiredTotal} required context doc
 757
 758            // Start with database docs
 1759            contextDocuments.AddRange(databaseContexts.Values);
 760
 761            // Add on-demand docs, skipping duplicates by name
 1762            var existingNames = new HashSet<string>(contextDocuments.Select(c => c.Name), StringComparer.OrdinalIgnoreCa
 1763            await foreach (var context in contextProvider.GetMatchContextAsync(homeTeam, awayTeam))
 764            {
 1765                if (existingNames.Add(context.Name))
 766                {
 1767                    contextDocuments.Add(context);
 768                }
 769            }
 770
 1771            if (verbose)
 772            {
 1773                _console.MarkupLine($"[yellow]    Using {contextDocuments.Count} merged context documents (database + on
 774            }
 1775        }
 776
 1777        if (CompetitionResolver.IsWorldCupCompetition(competition))
 778        {
 1779            EnsureWorldCupRequiredContextPresent(contextDocuments, requiredDocuments);
 780        }
 781
 1782        return contextDocuments;
 1783    }
 784
 785    private static void EnsureWorldCupRequiredContextPresent(
 786        IReadOnlyList<DocumentContext> contextDocuments,
 787        IReadOnlyList<string> requiredDocuments)
 788    {
 1789        var presentDocumentNames = new HashSet<string>(
 1790            contextDocuments.Select(document => document.Name),
 1791            StringComparer.OrdinalIgnoreCase);
 1792        var missingDocumentNames = requiredDocuments
 1793            .Where(documentName => !presentDocumentNames.Contains(documentName))
 1794            .ToList();
 795
 1796        if (missingDocumentNames.Count == 0)
 797        {
 1798            return;
 799        }
 800
 1801        throw new InvalidOperationException(
 1802            "Missing required WM26 context documents after database and on-demand fallback: " +
 1803            $"{string.Join(", ", missingDocumentNames)}. Seed FIFA rankings with collect-context fifa and lineups with c
 804    }
 805
 806    private async Task<bool> CheckPredictionOutdated(IPredictionRepository predictionRepository, IContextRepository cont
 807    {
 808        try
 809        {
 810            // Get prediction metadata with context document names and timestamps
 811            // For cancelled matches, use team-names-only lookup to handle startsAt inconsistencies
 812            PredictionMetadata? predictionMetadata;
 1813            if (match.IsCancelled)
 814            {
 1815                predictionMetadata = await predictionRepository.GetCancelledMatchPredictionMetadataAsync(
 1816                    match.HomeTeam, match.AwayTeam, modelConfig, communityContext);
 817            }
 818            else
 819            {
 1820                predictionMetadata = await predictionRepository.GetPredictionMetadataAsync(match, modelConfig, community
 821            }
 822
 1823            if (predictionMetadata == null || !predictionMetadata.ContextDocumentNames.Any())
 824            {
 825                // If no context documents were used, prediction can't be outdated based on context changes
 1826                return false;
 827            }
 828
 1829            if (verbose)
 830            {
 1831                _console.MarkupLine($"[dim]  Checking {predictionMetadata.ContextDocumentNames.Count} context documents 
 832            }
 833
 834            // Check if any context document has been updated after the prediction was created
 1835            foreach (var documentName in predictionMetadata.ContextDocumentNames)
 836            {
 837                // Strip any display suffix (e.g., " (kpi-context)") from the context document name
 838                // to get the actual document name stored in the repository
 1839                var actualDocumentName = StripDisplaySuffix(documentName);
 840
 1841                var standingsDocumentName = MatchContextDocumentCatalog.GetStandingsDocumentName(competition);
 1842                if (actualDocumentName.Equals(standingsDocumentName, StringComparison.OrdinalIgnoreCase))
 843                {
 1844                    if (verbose)
 845                    {
 1846                        _console.MarkupLine($"[dim]  Skipping outdated check for '{actualDocumentName}' (excluded from c
 847                    }
 1848                    continue;
 849                }
 850
 1851                var latestContextDocument = await contextRepository.GetLatestContextDocumentAsync(actualDocumentName, co
 852
 1853                if (latestContextDocument != null && latestContextDocument.CreatedAt > predictionMetadata.CreatedAt)
 854                {
 1855                    var predictionTimeContextDocument = await contextRepository.GetContextDocumentByTimestampAsync(
 1856                        actualDocumentName,
 1857                        predictionMetadata.CreatedAt,
 1858                        communityContext);
 859
 1860                    if (predictionTimeContextDocument != null &&
 1861                        string.Equals(
 1862                            predictionTimeContextDocument.Content,
 1863                            latestContextDocument.Content,
 1864                            StringComparison.Ordinal))
 865                    {
 1866                        if (verbose)
 867                        {
 1868                            _console.MarkupLine(
 1869                                $"[dim]  Context document '{actualDocumentName}' has newer versions after the prediction
 870                        }
 871
 1872                        continue;
 873                    }
 874
 1875                    if (verbose)
 876                    {
 1877                        _console.MarkupLine($"[dim]  Context document '{actualDocumentName}' (stored as '{documentName}'
 878                    }
 1879                    return true; // Prediction is outdated
 880                }
 1881                else if (verbose && latestContextDocument == null)
 882                {
 1883                    _console.MarkupLine($"[yellow]  Warning: Context document '{actualDocumentName}' not found in reposi
 884                }
 1885            }
 886
 1887            return false; // Prediction is up-to-date
 888        }
 1889        catch (Exception ex)
 890        {
 891            // Log error but don't fail verification due to outdated check issues
 1892            if (verbose)
 893            {
 1894                _console.MarkupLine($"[yellow]  Warning: Failed to check outdated status: {ex.Message}[/]");
 895            }
 1896            return false;
 897        }
 1898    }
 899
 900    private void WriteJustificationIfNeeded(Prediction? prediction, bool includeJustification, bool fromDatabase = false
 901    {
 1902        if (!includeJustification || prediction == null)
 903        {
 1904            return;
 905        }
 906
 1907        var sourceLabel = fromDatabase ? "stored prediction" : "model response";
 908
 1909        var justificationWriter = new JustificationConsoleWriter(_console);
 1910        justificationWriter.WriteJustification(
 1911            prediction.Justification,
 1912            "[dim]    ↳ Justification:[/]",
 1913            "        ",
 1914            $"[yellow]    ↳ No justification available for this {sourceLabel}[/]");
 1915    }
 916
 917    /// <summary>
 918    /// Strips display suffixes like " (kpi-context)" from context document names
 919    /// to get the actual document name used in the repository.
 920    /// </summary>
 921    /// <param name="displayName">The display name that may contain a suffix</param>
 922    /// <returns>The actual document name without any display suffix</returns>
 923    private static string StripDisplaySuffix(string displayName)
 924    {
 925        // Look for patterns like " (some-text)" at the end and remove them
 1926        var lastParenIndex = displayName.LastIndexOf(" (");
 1927        if (lastParenIndex > 0 && displayName.EndsWith(")"))
 928        {
 1929            return displayName.Substring(0, lastParenIndex);
 930        }
 1931        return displayName;
 932    }
 933}