< 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: 434
Uncovered lines: 2
Coverable lines: 436
Total lines: 914
Line coverage: 99.5%
Branch coverage
96%
Covered branches: 238
Total branches: 247
Branch coverage: 96.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
ExecuteAsync()100%3030100%
.cctor()100%11100%
ExecuteMatchdayWorkflow()95.04%141141100%
GetMatchContextDocumentsAsync()100%2020100%
GetHybridContextAsync()100%1010100%
GetTeamAbbreviation(...)90%1010100%
CheckPredictionOutdated()100%2626100%
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;
 12
 13namespace Orchestrator.Commands.Operations.Matchday;
 14
 15public class MatchdayCommand : AsyncCommand<BaseSettings>
 16{
 17    private readonly IAnsiConsole _console;
 18    private readonly IFirebaseServiceFactory _firebaseServiceFactory;
 19    private readonly IKicktippClientFactory _kicktippClientFactory;
 20    private readonly IOpenAiServiceFactory _openAiServiceFactory;
 21    private readonly IContextProviderFactory _contextProviderFactory;
 22    private readonly ILogger<MatchdayCommand> _logger;
 23
 124    public MatchdayCommand(
 125        IAnsiConsole console,
 126        IFirebaseServiceFactory firebaseServiceFactory,
 127        IKicktippClientFactory kicktippClientFactory,
 128        IOpenAiServiceFactory openAiServiceFactory,
 129        IContextProviderFactory contextProviderFactory,
 130        ILogger<MatchdayCommand> logger)
 31    {
 132        _console = console;
 133        _firebaseServiceFactory = firebaseServiceFactory;
 134        _kicktippClientFactory = kicktippClientFactory;
 135        _openAiServiceFactory = openAiServiceFactory;
 136        _contextProviderFactory = contextProviderFactory;
 137        _logger = logger;
 138    }
 39
 40    public override async Task<int> ExecuteAsync(CommandContext context, BaseSettings settings)
 41    {
 42
 43        try
 44        {
 145            _console.MarkupLine($"[green]Matchday command initialized with model:[/] [yellow]{settings.Model}[/]");
 46
 147            if (settings.Verbose)
 48            {
 149                _console.MarkupLine("[dim]Verbose mode enabled[/]");
 50            }
 51
 152            if (settings.OverrideKicktipp)
 53            {
 154                _console.MarkupLine("[yellow]Override mode enabled - will override existing Kicktipp predictions[/]");
 55            }
 56
 157            if (settings.OverrideDatabase)
 58            {
 159                _console.MarkupLine("[yellow]Override database mode enabled - will override existing database prediction
 60            }
 61
 162            if (settings.Agent)
 63            {
 164                _console.MarkupLine("[blue]Agent mode enabled - prediction details will be hidden[/]");
 65            }
 66
 167            if (settings.DryRun)
 68            {
 169                _console.MarkupLine("[magenta]Dry run mode enabled - no changes will be made to database or Kicktipp[/]"
 70            }
 71
 172            if (!string.IsNullOrEmpty(settings.EstimatedCostsModel))
 73            {
 174                _console.MarkupLine($"[cyan]Estimated costs will be calculated for model:[/] [yellow]{settings.Estimated
 75            }
 76
 177            if (settings.WithJustification)
 78            {
 179                if (settings.Agent)
 80                {
 181                    _console.MarkupLine("[red]Error:[/] --with-justification cannot be used with --agent");
 182                    return 1;
 83                }
 84
 185                _console.MarkupLine("[green]Justification output enabled - model reasoning will be captured[/]");
 86            }
 87
 88            // Validate reprediction settings
 189            if (settings.OverrideDatabase && settings.IsRepredictMode)
 90            {
 191                _console.MarkupLine($"[red]Error:[/] --override-database cannot be used with reprediction flags (--repre
 192                return 1;
 93            }
 94
 195            if (settings.MaxRepredictions.HasValue && settings.MaxRepredictions.Value < 0)
 96            {
 197                _console.MarkupLine($"[red]Error:[/] --max-repredictions must be 0 or greater");
 198                return 1;
 99            }
 100
 1101            if (settings.IsRepredictMode)
 102            {
 1103                var maxValue = settings.MaxRepredictions ?? int.MaxValue;
 1104                _console.MarkupLine($"[yellow]Reprediction mode enabled - max repredictions: {(settings.MaxRepredictions
 105            }
 106
 107            // Execute the matchday workflow
 1108            await ExecuteMatchdayWorkflow(settings);
 109
 1110            return 0;
 111        }
 1112        catch (Exception ex)
 113        {
 1114            _logger.LogError(ex, "Error executing matchday command");
 1115            _console.MarkupLine($"[red]Error:[/] {ex.Message}");
 1116            return 1;
 117        }
 1118    }
 119
 120    /// <summary>
 121    /// Communities that have production workflows invoking the matchday command.
 122    /// Update this set when adding or removing community matchday workflows in .github/workflows/.
 123    /// See .github/workflows/AGENTS.md for details.
 124    /// </summary>
 1125    private static readonly HashSet<string> ProductionCommunities = new(StringComparer.OrdinalIgnoreCase)
 1126    {
 1127        "schadensfresse",
 1128        "pes-squad",
 1129        "ehonda-ai-arena"
 1130    };
 131
 132    private async Task ExecuteMatchdayWorkflow(BaseSettings settings)
 133    {
 134        // Start root OTel activity for Langfuse trace
 1135        using var activity = Telemetry.Source.StartActivity("matchday");
 136
 137        // Set Langfuse environment based on community
 1138        var environment = ProductionCommunities.Contains(settings.Community) ? "production" : "development";
 1139        LangfuseActivityPropagation.SetEnvironment(activity, environment);
 140
 141        // Create services using factories
 1142        var kicktippClient = _kicktippClientFactory.CreateClient();
 1143        var predictionService = _openAiServiceFactory.CreatePredictionService(settings.Model);
 144
 145        // Create context provider using factory
 1146        string communityContext = settings.CommunityContext ?? settings.Community;
 1147        var contextProvider = _contextProviderFactory.CreateKicktippContextProvider(
 1148            kicktippClient, settings.Community, communityContext);
 149
 1150        var tokenUsageTracker = _openAiServiceFactory.GetTokenUsageTracker();
 151
 152        // Log the prompt paths being used
 1153        if (settings.Verbose)
 154        {
 1155            _console.MarkupLine($"[dim]Match prompt:[/] [blue]{predictionService.GetMatchPromptPath(settings.WithJustifi
 156        }
 157
 158        // Create repositories
 1159        var predictionRepository = _firebaseServiceFactory.CreatePredictionRepository();
 1160        var contextRepository = _firebaseServiceFactory.CreateContextRepository();
 1161        var databaseEnabled = true;
 162
 163        // Reset token usage tracker for this workflow
 1164        tokenUsageTracker.Reset();
 165
 1166        _console.MarkupLine($"[blue]Using community:[/] [yellow]{settings.Community}[/]");
 1167        _console.MarkupLine($"[blue]Using community context:[/] [yellow]{communityContext}[/]");
 1168        _console.MarkupLine("[blue]Getting current matchday matches...[/]");
 169
 170        // Step 1: Get current matchday via GetMatchesWithHistoryAsync
 1171        var matchesWithHistory = await kicktippClient.GetMatchesWithHistoryAsync(settings.Community);
 172
 1173        if (!matchesWithHistory.Any())
 174        {
 1175            _console.MarkupLine("[yellow]No matches found for current matchday[/]");
 1176            return;
 177        }
 178
 179        // Set Langfuse trace-level attributes now that we know the matchday
 1180        var matchday = matchesWithHistory.First().Match.Matchday;
 1181        var sessionId = $"matchday-{matchday}-{settings.Community}";
 1182        var traceTags = new[] { settings.Community, settings.Model };
 1183        LangfuseActivityPropagation.SetSessionId(activity, sessionId);
 1184        LangfuseActivityPropagation.SetTraceTags(activity, traceTags);
 1185        LangfuseActivityPropagation.SetTraceMetadata(activity, "community", settings.Community);
 1186        LangfuseActivityPropagation.SetTraceMetadata(activity, "communityContext", communityContext);
 1187        LangfuseActivityPropagation.SetTraceMetadata(activity, "kicktipp-season", KicktippSeasonMetadata.Current);
 1188        LangfuseActivityPropagation.SetTraceMetadata(activity, "matchday", matchday.ToString());
 1189        LangfuseActivityPropagation.SetTraceMetadata(activity, "model", settings.Model);
 1190        LangfuseActivityPropagation.SetTraceMetadata(activity, "homeTeams", PredictionTelemetryMetadata.BuildDelimitedFi
 1191        LangfuseActivityPropagation.SetTraceMetadata(activity, "awayTeams", PredictionTelemetryMetadata.BuildDelimitedFi
 1192        LangfuseActivityPropagation.SetTraceMetadata(activity, "teams", PredictionTelemetryMetadata.BuildDelimitedFilter
 1193        LangfuseActivityPropagation.SetTraceMetadata(activity, "repredictMode", settings.IsRepredictMode ? "true" : "fal
 194
 195        // Set trace input
 1196        var traceInput = new
 1197        {
 1198            community = settings.Community,
 1199            matchday,
 1200            model = settings.Model,
 1201            matches = matchesWithHistory.Select(m => $"{m.Match.HomeTeam} vs {m.Match.AwayTeam}").ToArray()
 1202        };
 1203        activity?.SetTag("langfuse.trace.input", JsonSerializer.Serialize(traceInput));
 204
 1205        _console.MarkupLine($"[green]Found {matchesWithHistory.Count} matches for current matchday[/]");
 206
 1207        if (databaseEnabled)
 208        {
 1209            _console.MarkupLine("[blue]Database enabled - checking for existing predictions...[/]");
 210        }
 211
 1212        var predictions = new Dictionary<Match, BetPrediction>();
 1213        var traceRepredictionIndices = new HashSet<string>(StringComparer.Ordinal);
 214
 215        // Step 2: For each match, check database first, then predict if needed
 1216        foreach (var matchWithHistory in matchesWithHistory)
 217        {
 1218            var match = matchWithHistory.Match;
 219
 220            // Log warning for cancelled matches - they have inherited times which may affect database operations
 1221            if (match.IsCancelled)
 222            {
 1223                _console.MarkupLine($"[yellow]  ⚠ {match.HomeTeam} vs {match.AwayTeam} is cancelled (Abgesagt). " +
 1224                    $"Processing with inherited time - prediction may need re-evaluation when rescheduled.[/]");
 225            }
 226
 1227            _console.MarkupLine($"[cyan]Processing:[/] {match.HomeTeam} vs {match.AwayTeam}{(match.IsCancelled ? " [yell
 228
 229            try
 230            {
 1231                Prediction? prediction = null;
 1232                bool fromDatabase = false;
 1233                bool shouldPredict = false;
 1234                int? predictionRepredictionIndex = settings.IsRepredictMode ? null : 0;
 235
 236                // Check if we have an existing prediction in the database
 1237                if (databaseEnabled && !settings.OverrideDatabase && !settings.IsRepredictMode)
 238                {
 239                    // For cancelled matches, use team-names-only lookup to handle startsAt inconsistencies
 240                    // See IPredictionRepository.cs for detailed documentation on this edge case
 1241                    if (match.IsCancelled)
 242                    {
 1243                        prediction = await predictionRepository!.GetCancelledMatchPredictionAsync(
 1244                            match.HomeTeam, match.AwayTeam, settings.Model, communityContext);
 245                    }
 246                    else
 247                    {
 1248                        prediction = await predictionRepository!.GetPredictionAsync(match, settings.Model, communityCont
 249                    }
 250
 1251                    if (prediction != null)
 252                    {
 1253                        fromDatabase = true;
 1254                        if (settings.Agent)
 255                        {
 1256                            _console.MarkupLine($"[green]  ✓ Found existing prediction[/] [dim](from database)[/]");
 257                        }
 258                        else
 259                        {
 1260                            _console.MarkupLine($"[green]  ✓ Found existing prediction:[/] {prediction.HomeGoals}:{predi
 1261                            WriteJustificationIfNeeded(prediction, settings.WithJustification, fromDatabase: true);
 262                        }
 263                    }
 264                }
 265
 266                // Handle reprediction logic
 1267                if (settings.IsRepredictMode && databaseEnabled)
 268                {
 269                    // For cancelled matches, use team-names-only lookup to handle startsAt inconsistencies
 270                    int currentRepredictionIndex;
 1271                    if (match.IsCancelled)
 272                    {
 1273                        currentRepredictionIndex = await predictionRepository!.GetCancelledMatchRepredictionIndexAsync(
 1274                            match.HomeTeam, match.AwayTeam, settings.Model, communityContext);
 275                    }
 276                    else
 277                    {
 1278                        currentRepredictionIndex = await predictionRepository!.GetMatchRepredictionIndexAsync(match, set
 279                    }
 280
 1281                    if (currentRepredictionIndex == -1)
 282                    {
 283                        // No prediction exists yet - create first prediction
 1284                        shouldPredict = true;
 1285                        predictionRepredictionIndex = 0;
 1286                        _console.MarkupLine($"[yellow]  → No existing prediction found, creating first prediction...[/]"
 287                    }
 288                    else
 289                    {
 290                        // Check if we can create another reprediction
 1291                        var maxAllowed = settings.MaxRepredictions ?? int.MaxValue;
 1292                        var nextIndex = currentRepredictionIndex + 1;
 293
 1294                        if (nextIndex <= maxAllowed)
 295                        {
 296                            // Before repredicting, check if the current prediction is actually outdated
 1297                            var isOutdated = await CheckPredictionOutdated(predictionRepository!, contextRepository, mat
 298
 1299                            if (isOutdated)
 300                            {
 1301                                shouldPredict = true;
 1302                                predictionRepredictionIndex = nextIndex;
 1303                                _console.MarkupLine($"[yellow]  → Creating reprediction {nextIndex} (current: {currentRe
 304                            }
 305                            else
 306                            {
 1307                                traceRepredictionIndices.Add(currentRepredictionIndex.ToString());
 1308                                _console.MarkupLine($"[green]  ✓ Skipped reprediction - current prediction is up-to-date
 309
 310                                // Get the latest prediction for display purposes
 311                                // For cancelled matches, use team-names-only lookup
 1312                                if (match.IsCancelled)
 313                                {
 1314                                    prediction = await predictionRepository!.GetCancelledMatchPredictionAsync(
 1315                                        match.HomeTeam, match.AwayTeam, settings.Model, communityContext);
 316                                }
 317                                else
 318                                {
 1319                                    prediction = await predictionRepository!.GetPredictionAsync(match, settings.Model, c
 320                                }
 321
 1322                                if (prediction != null)
 323                                {
 1324                                    fromDatabase = true;
 1325                                    if (!settings.Agent)
 326                                    {
 1327                                        _console.MarkupLine($"[green]  ✓ Latest prediction:[/] {prediction.HomeGoals}:{p
 1328                                        WriteJustificationIfNeeded(prediction, settings.WithJustification, fromDatabase:
 329                                    }
 330                                }
 331                            }
 332                        }
 333                        else
 334                        {
 1335                            traceRepredictionIndices.Add(currentRepredictionIndex.ToString());
 1336                            _console.MarkupLine($"[yellow]  ✗ Skipped - already at max repredictions ({currentRepredicti
 337
 338                            // Get the latest prediction for display purposes
 339                            // For cancelled matches, use team-names-only lookup
 1340                            if (match.IsCancelled)
 341                            {
 1342                                prediction = await predictionRepository!.GetCancelledMatchPredictionAsync(
 1343                                    match.HomeTeam, match.AwayTeam, settings.Model, communityContext);
 344                            }
 345                            else
 346                            {
 1347                                prediction = await predictionRepository!.GetPredictionAsync(match, settings.Model, commu
 348                            }
 349
 1350                            if (prediction != null)
 351                            {
 1352                                fromDatabase = true;
 1353                                if (!settings.Agent)
 354                                {
 1355                                    _console.MarkupLine($"[green]  ✓ Latest prediction:[/] {prediction.HomeGoals}:{predi
 1356                                    WriteJustificationIfNeeded(prediction, settings.WithJustification, fromDatabase: tru
 357                                }
 358                            }
 359                        }
 360                    }
 361                }
 362
 363                // If no existing prediction (normal mode) or we need to predict (reprediction mode), generate a new one
 1364                if (prediction == null || shouldPredict)
 365                {
 1366                    _console.MarkupLine($"[yellow]  → Generating new prediction...[/]");
 367
 368                    // Step 3: Get context using hybrid approach (database first, fallback to on-demand)
 1369                    var contextDocuments = await GetHybridContextAsync(
 1370                        contextRepository,
 1371                        contextProvider,
 1372                        match.HomeTeam,
 1373                        match.AwayTeam,
 1374                        communityContext,
 1375                        settings.Verbose);
 376
 1377                    if (settings.Verbose)
 378                    {
 1379                        _console.MarkupLine($"[dim]    Using {contextDocuments.Count} context documents[/]");
 380                    }
 381
 382                    // Show context documents if requested
 1383                    if (settings.ShowContextDocuments)
 384                    {
 1385                        _console.MarkupLine($"[cyan]    Context documents for {match.HomeTeam} vs {match.AwayTeam}:[/]")
 1386                        foreach (var doc in contextDocuments)
 387                        {
 1388                            _console.MarkupLine($"[dim]    📄 {doc.Name}[/]");
 389
 390                            // Show first few lines and total line count for readability
 1391                            var lines = doc.Content.Split('\n');
 1392                            var previewLines = lines.Take(10).ToArray();
 1393                            var hasMore = lines.Length > 10;
 394
 1395                            foreach (var line in previewLines)
 396                            {
 1397                                _console.MarkupLine($"[grey]      {line.EscapeMarkup()}[/]");
 398                            }
 399
 1400                            if (hasMore)
 401                            {
 1402                                _console.MarkupLine($"[dim]      ... ({lines.Length - 10} more lines) ...[/]");
 403                            }
 404
 1405                            _console.MarkupLine($"[dim]      (Total: {lines.Length} lines, {doc.Content.Length} characte
 1406                            _console.WriteLine();
 407                        }
 408                    }
 409
 1410                    var telemetryMetadata = new PredictionTelemetryMetadata(
 1411                        HomeTeam: match.HomeTeam,
 1412                        AwayTeam: match.AwayTeam,
 1413                        RepredictionIndex: predictionRepredictionIndex);
 414
 415                    // Predict the match
 1416                    prediction = await predictionService.PredictMatchAsync(match, contextDocuments, settings.WithJustifi
 417
 1418                    if (prediction != null)
 419                    {
 1420                        if (predictionRepredictionIndex.HasValue)
 421                        {
 1422                            traceRepredictionIndices.Add(predictionRepredictionIndex.Value.ToString());
 423                        }
 424
 1425                        if (settings.Agent)
 426                        {
 1427                            _console.MarkupLine($"[green]  ✓ Generated prediction[/]");
 428                        }
 429                        else
 430                        {
 1431                            _console.MarkupLine($"[green]  ✓ Generated prediction:[/] {prediction.HomeGoals}:{prediction
 1432                            WriteJustificationIfNeeded(prediction, settings.WithJustification);
 433                        }
 434
 435                        // Save to database immediately if enabled
 1436                        if (databaseEnabled && !settings.DryRun)
 437                        {
 438                            try
 439                            {
 440                                // Get token usage and cost information
 1441                                var cost = (double)tokenUsageTracker.GetLastCost(); // Get the cost for this individual 
 442                                // Use the new GetLastUsageJson method to get full JSON
 1443                                var tokenUsageJson = tokenUsageTracker.GetLastUsageJson() ?? "{}";
 444
 1445                                if (settings.IsRepredictMode)
 446                                {
 447                                    // Save as reprediction with specific index
 448                                    // For cancelled matches, use team-names-only lookup for the current index
 449                                    int currentIndex;
 1450                                    if (match.IsCancelled)
 451                                    {
 1452                                        currentIndex = await predictionRepository!.GetCancelledMatchRepredictionIndexAsy
 1453                                            match.HomeTeam, match.AwayTeam, settings.Model, communityContext);
 454                                    }
 455                                    else
 456                                    {
 1457                                        currentIndex = await predictionRepository!.GetMatchRepredictionIndexAsync(match,
 458                                    }
 1459                                    var nextIndex = currentIndex == -1 ? 0 : currentIndex + 1;
 460
 1461                                    await predictionRepository!.SaveRepredictionAsync(
 1462                                        match,
 1463                                        prediction,
 1464                                        settings.Model,
 1465                                        tokenUsageJson,
 1466                                        cost,
 1467                                        communityContext,
 0468                                        contextDocuments.Select(d => d.Name),
 1469                                        nextIndex);
 470
 1471                                    if (settings.Verbose)
 472                                    {
 1473                                        _console.MarkupLine($"[dim]    ✓ Saved as reprediction {nextIndex} to database[/
 474                                    }
 475                                }
 476                                else
 477                                {
 478                                    // Save normally (override or new prediction)
 1479                                    await predictionRepository!.SavePredictionAsync(
 1480                                        match,
 1481                                        prediction,
 1482                                        settings.Model,
 1483                                        tokenUsageJson,
 1484                                        cost,
 1485                                        communityContext,
 0486                                        contextDocuments.Select(d => d.Name),
 1487                                        overrideCreatedAt: settings.OverrideDatabase);
 488
 1489                                    if (settings.Verbose)
 490                                    {
 1491                                        _console.MarkupLine($"[dim]    ✓ Saved to database[/]");
 492                                    }
 493                                }
 1494                            }
 1495                            catch (Exception ex)
 496                            {
 1497                                _logger.LogError(ex, "Failed to save prediction for match {Match}", match);
 1498                                _console.MarkupLine($"[red]    ✗ Failed to save to database: {ex.Message}[/]");
 1499                            }
 500                        }
 1501                        else if (databaseEnabled && settings.DryRun && settings.Verbose)
 502                        {
 1503                            _console.MarkupLine($"[dim]    (Dry run - skipped database save)[/]");
 504                        }
 505
 506                        // Show individual match token usage in verbose mode
 1507                        if (settings.Verbose)
 508                        {
 1509                            var matchUsage = !string.IsNullOrEmpty(settings.EstimatedCostsModel)
 1510                                ? tokenUsageTracker.GetLastUsageCompactSummaryWithEstimatedCosts(settings.EstimatedCosts
 1511                                : tokenUsageTracker.GetLastUsageCompactSummary();
 1512                            _console.MarkupLine($"[dim]    Token usage: {matchUsage}[/]");
 513                        }
 514                    }
 515                    else
 516                    {
 1517                        _console.MarkupLine($"[red]  ✗ Failed to generate prediction[/]");
 1518                        continue;
 519                    }
 1520                }
 521
 522                // Convert to BetPrediction for Kicktipp
 1523                var betPrediction = new BetPrediction(prediction.HomeGoals, prediction.AwayGoals);
 1524                predictions[match] = betPrediction;
 525
 1526                if (!fromDatabase && settings.Verbose)
 527                {
 1528                    _console.MarkupLine($"[dim]    Already saved to database[/]");
 529                }
 1530            }
 1531            catch (Exception ex)
 532            {
 1533                _logger.LogError(ex, "Error processing match {Match}", match);
 1534                _console.MarkupLine($"[red]  ✗ Error processing match: {ex.Message}[/]");
 1535            }
 1536        }
 537
 1538        if (traceRepredictionIndices.Count > 0)
 539        {
 1540            LangfuseActivityPropagation.SetTraceMetadata(activity, "repredictionIndices", PredictionTelemetryMetadata.Bu
 1541            LangfuseActivityPropagation.SetTraceMetadata(activity, "hasRepredictions", traceRepredictionIndices.Any(inde
 542        }
 543
 1544        if (!predictions.Any())
 545        {
 1546            _console.MarkupLine("[yellow]No predictions available, nothing to place[/]");
 1547            activity?.SetTag("langfuse.trace.output", JsonSerializer.Serialize(new { error = "No predictions available" 
 1548            return;
 549        }
 550
 551        // Set trace output with all predictions
 1552        var traceOutput = predictions.Select(p => new
 1553        {
 1554            match = $"{p.Key.HomeTeam} vs {p.Key.AwayTeam}",
 1555            prediction = $"{p.Value.HomeGoals}:{p.Value.AwayGoals}"
 1556        }).ToArray();
 1557        activity?.SetTag("langfuse.trace.output", JsonSerializer.Serialize(traceOutput));
 558
 559        // Step 4: Place all predictions using PlaceBetsAsync
 1560        _console.MarkupLine($"[blue]Placing {predictions.Count} predictions to Kicktipp...[/]");
 561
 1562        if (settings.DryRun)
 563        {
 1564            _console.MarkupLine($"[magenta]✓ Dry run mode - would have placed {predictions.Count} predictions (no actual
 565        }
 566        else
 567        {
 1568            var success = await kicktippClient.PlaceBetsAsync(settings.Community, predictions, overrideBets: settings.Ov
 569
 1570            if (success)
 571            {
 1572                _console.MarkupLine($"[green]✓ Successfully placed all {predictions.Count} predictions![/]");
 573            }
 574            else
 575            {
 1576                _console.MarkupLine("[red]✗ Failed to place some or all predictions[/]");
 577            }
 578        }
 579
 580        // Display token usage summary
 1581        var summary = !string.IsNullOrEmpty(settings.EstimatedCostsModel)
 1582            ? tokenUsageTracker.GetCompactSummaryWithEstimatedCosts(settings.EstimatedCostsModel)
 1583            : tokenUsageTracker.GetCompactSummary();
 1584        _console.MarkupLine($"[dim]Token usage (uncached/cached/reasoning/output/$cost): {summary}[/]");
 1585    }
 586
 587    /// <summary>
 588    /// Retrieves all available context documents from the database for the given community context.
 589    /// </summary>
 590    private async Task<Dictionary<string, DocumentContext>> GetMatchContextDocumentsAsync(
 591        IContextRepository contextRepository,
 592        string homeTeam,
 593        string awayTeam,
 594        string communityContext,
 595        bool verbose = false)
 596    {
 1597        var contextDocuments = new Dictionary<string, DocumentContext>();
 1598        var homeAbbreviation = GetTeamAbbreviation(homeTeam);
 1599        var awayAbbreviation = GetTeamAbbreviation(awayTeam);
 600
 601        // Define the 7 specific document names needed for a match (required core set)
 1602        var requiredDocuments = new[]
 1603        {
 1604            "bundesliga-standings.csv",
 1605            $"community-rules-{communityContext}.md",
 1606            $"recent-history-{homeAbbreviation}.csv",
 1607            $"recent-history-{awayAbbreviation}.csv",
 1608            $"home-history-{homeAbbreviation}.csv",
 1609            $"away-history-{awayAbbreviation}.csv",
 1610            $"head-to-head-{homeAbbreviation}-vs-{awayAbbreviation}.csv"
 1611        };
 612
 613        // Optional transfers documents (do not affect required count). Naming: <abbr>-transfers.csv
 1614        var optionalDocuments = new[]
 1615        {
 1616            $"{homeAbbreviation}-transfers.csv",
 1617            $"{awayAbbreviation}-transfers.csv"
 1618        };
 619
 1620        if (verbose)
 621        {
 1622            _console.MarkupLine($"[dim]    Looking for {requiredDocuments.Length} specific context documents in database
 623        }
 624
 625        try
 626        {
 627            // Retrieve each required document
 1628            foreach (var documentName in requiredDocuments)
 629            {
 1630                var contextDoc = await contextRepository.GetLatestContextDocumentAsync(documentName, communityContext);
 1631                if (contextDoc != null)
 632                {
 1633                    contextDocuments[documentName] = new DocumentContext(contextDoc.DocumentName, contextDoc.Content);
 634
 1635                    if (verbose)
 636                    {
 1637                        _console.MarkupLine($"[dim]      ✓ Retrieved {documentName} (version {contextDoc.Version})[/]");
 638                    }
 639                }
 640                else
 641                {
 1642                    if (verbose)
 643                    {
 1644                        _console.MarkupLine($"[dim]      ✗ Missing {documentName}[/]");
 645                    }
 646                }
 1647            }
 648
 649            // Retrieve optional transfers documents (best-effort)
 1650            foreach (var documentName in optionalDocuments)
 651            {
 652                try
 653                {
 1654                    var contextDoc = await contextRepository.GetLatestContextDocumentAsync(documentName, communityContex
 1655                    if (contextDoc != null)
 656                    {
 657                        // Display name suffix to distinguish optional docs in prediction metadata (helps debug)
 1658                        contextDocuments[documentName] = new DocumentContext(contextDoc.DocumentName, contextDoc.Content
 1659                        if (verbose)
 660                        {
 1661                            _console.MarkupLine($"[dim]      ✓ Retrieved optional {documentName} (version {contextDoc.Ve
 662                        }
 663                    }
 1664                    else if (verbose)
 665                    {
 1666                        _console.MarkupLine($"[dim]      · Missing optional {documentName}[/]");
 667                    }
 1668                }
 1669                catch (Exception optEx)
 670                {
 1671                    if (verbose)
 672                    {
 1673                        _console.MarkupLine($"[dim]      · Failed optional {documentName}: {optEx.Message}[/]");
 674                    }
 1675                }
 1676            }
 1677        }
 1678        catch (Exception ex)
 679        {
 1680            _console.MarkupLine($"[red]    Warning: Failed to retrieve context from database: {ex.Message}[/]");
 1681        }
 682
 1683        return contextDocuments;
 1684    }
 685
 686    /// <summary>
 687    /// Gets context documents using database first, falling back to on-demand context provider if needed.
 688    /// </summary>
 689    private async Task<List<DocumentContext>> GetHybridContextAsync(
 690        IContextRepository contextRepository,
 691        IKicktippContextProvider contextProvider,
 692        string homeTeam,
 693        string awayTeam,
 694        string communityContext,
 695        bool verbose = false)
 696    {
 1697        var contextDocuments = new List<DocumentContext>();
 698        // Step 1: Retrieve any database documents (required + optional)
 1699        var databaseContexts = await GetMatchContextDocumentsAsync(
 1700            contextRepository,
 1701            homeTeam,
 1702            awayTeam,
 1703            communityContext,
 1704            verbose);
 705
 706        // Reconstruct required document names (must match logic in GetMatchContextDocumentsAsync)
 1707        var homeAbbreviation = GetTeamAbbreviation(homeTeam);
 1708        var awayAbbreviation = GetTeamAbbreviation(awayTeam);
 1709        var requiredDocuments = new[]
 1710        {
 1711            "bundesliga-standings.csv",
 1712            $"community-rules-{communityContext}.md",
 1713            $"recent-history-{homeAbbreviation}.csv",
 1714            $"recent-history-{awayAbbreviation}.csv",
 1715            $"home-history-{homeAbbreviation}.csv",
 1716            $"away-history-{awayAbbreviation}.csv",
 1717            $"head-to-head-{homeAbbreviation}-vs-{awayAbbreviation}.csv"
 1718        };
 719
 1720        int requiredPresent = requiredDocuments.Count(d => databaseContexts.ContainsKey(d));
 1721        int requiredTotal = requiredDocuments.Length;
 722
 1723        if (requiredPresent == requiredTotal)
 724        {
 725            // All required docs present; include every database doc (required + optional)
 1726            if (verbose)
 727            {
 1728                _console.MarkupLine($"[green]    Using {databaseContexts.Count} context documents from database (all req
 729            }
 1730            contextDocuments.AddRange(databaseContexts.Values);
 731        }
 732        else
 733        {
 734            // Fallback: use on-demand provider but still include any database docs we already have (including optional 
 1735            _console.MarkupLine($"[yellow]    Warning: Only found {requiredPresent}/{requiredTotal} required context doc
 736
 737            // Start with database docs
 1738            contextDocuments.AddRange(databaseContexts.Values);
 739
 740            // Add on-demand docs, skipping duplicates by name
 1741            var existingNames = new HashSet<string>(contextDocuments.Select(c => c.Name), StringComparer.OrdinalIgnoreCa
 1742            await foreach (var context in contextProvider.GetMatchContextAsync(homeTeam, awayTeam))
 743            {
 1744                if (existingNames.Add(context.Name))
 745                {
 1746                    contextDocuments.Add(context);
 747                }
 748            }
 749
 1750            if (verbose)
 751            {
 1752                _console.MarkupLine($"[yellow]    Using {contextDocuments.Count} merged context documents (database + on
 753            }
 1754        }
 755
 1756        return contextDocuments;
 1757    }
 758
 759    /// <summary>
 760    /// Gets a team abbreviation for file naming.
 761    /// </summary>
 762    private static string GetTeamAbbreviation(string teamName)
 763    {
 764        // Current season team abbreviations (2025-26 Bundesliga participants)
 1765        var abbreviations = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
 1766        {
 1767            { "1. FC Heidenheim 1846", "fch" },
 1768            { "1. FC Köln", "fck" },
 1769            { "1. FC Union Berlin", "fcu" },
 1770            { "1899 Hoffenheim", "tsg" },
 1771            { "Bayer 04 Leverkusen", "b04" },
 1772            { "Bor. Mönchengladbach", "bmg" },
 1773            { "Borussia Dortmund", "bvb" },
 1774            { "Eintracht Frankfurt", "sge" },
 1775            { "FC Augsburg", "fca" },
 1776            { "FC Bayern München", "fcb" },
 1777            { "FC St. Pauli", "fcs" },
 1778            { "FSV Mainz 05", "m05" },
 1779            { "Hamburger SV", "hsv" },
 1780            { "RB Leipzig", "rbl" },
 1781            { "SC Freiburg", "scf" },
 1782            { "VfB Stuttgart", "vfb" },
 1783            { "VfL Wolfsburg", "wob" },
 1784            { "Werder Bremen", "svw" }
 1785        };
 786
 1787        if (abbreviations.TryGetValue(teamName, out var abbreviation))
 788        {
 1789            return abbreviation;
 790        }
 791
 792        // Fallback: create abbreviation from team name
 1793        var words = teamName.Split(' ', StringSplitOptions.RemoveEmptyEntries);
 1794        var abbr = new System.Text.StringBuilder();
 795
 1796        foreach (var word in words.Take(3)) // Take up to 3 words
 797        {
 1798            if (word.Length > 0 && char.IsLetter(word[0]))
 799            {
 1800                abbr.Append(char.ToLowerInvariant(word[0]));
 801            }
 802        }
 803
 1804        return abbr.Length > 0 ? abbr.ToString() : "unknown";
 805    }
 806
 807    private async Task<bool> CheckPredictionOutdated(IPredictionRepository predictionRepository, IContextRepository cont
 808    {
 809        try
 810        {
 811            // Get prediction metadata with context document names and timestamps
 812            // For cancelled matches, use team-names-only lookup to handle startsAt inconsistencies
 813            PredictionMetadata? predictionMetadata;
 1814            if (match.IsCancelled)
 815            {
 1816                predictionMetadata = await predictionRepository.GetCancelledMatchPredictionMetadataAsync(
 1817                    match.HomeTeam, match.AwayTeam, model, communityContext);
 818            }
 819            else
 820            {
 1821                predictionMetadata = await predictionRepository.GetPredictionMetadataAsync(match, model, communityContex
 822            }
 823
 1824            if (predictionMetadata == null || !predictionMetadata.ContextDocumentNames.Any())
 825            {
 826                // If no context documents were used, prediction can't be outdated based on context changes
 1827                return false;
 828            }
 829
 1830            if (verbose)
 831            {
 1832                _console.MarkupLine($"[dim]  Checking {predictionMetadata.ContextDocumentNames.Count} context documents 
 833            }
 834
 835            // Check if any context document has been updated after the prediction was created
 1836            foreach (var documentName in predictionMetadata.ContextDocumentNames)
 837            {
 838                // Strip any display suffix (e.g., " (kpi-context)") from the context document name
 839                // to get the actual document name stored in the repository
 1840                var actualDocumentName = StripDisplaySuffix(documentName);
 841
 842                // Skip bundesliga-standings.csv from outdated check to reduce unnecessary repredictions
 1843                if (actualDocumentName.Equals("bundesliga-standings.csv", StringComparison.OrdinalIgnoreCase))
 844                {
 1845                    if (verbose)
 846                    {
 1847                        _console.MarkupLine($"[dim]  Skipping outdated check for '{actualDocumentName}' (excluded from c
 848                    }
 1849                    continue;
 850                }
 851
 1852                var latestContextDocument = await contextRepository.GetLatestContextDocumentAsync(actualDocumentName, co
 853
 1854                if (latestContextDocument != null && latestContextDocument.CreatedAt > predictionMetadata.CreatedAt)
 855                {
 1856                    if (verbose)
 857                    {
 1858                        _console.MarkupLine($"[dim]  Context document '{actualDocumentName}' (stored as '{documentName}'
 859                    }
 1860                    return true; // Prediction is outdated
 861                }
 1862                else if (verbose && latestContextDocument == null)
 863                {
 1864                    _console.MarkupLine($"[yellow]  Warning: Context document '{actualDocumentName}' not found in reposi
 865                }
 1866            }
 867
 1868            return false; // Prediction is up-to-date
 869        }
 1870        catch (Exception ex)
 871        {
 872            // Log error but don't fail verification due to outdated check issues
 1873            if (verbose)
 874            {
 1875                _console.MarkupLine($"[yellow]  Warning: Failed to check outdated status: {ex.Message}[/]");
 876            }
 1877            return false;
 878        }
 1879    }
 880
 881    private void WriteJustificationIfNeeded(Prediction? prediction, bool includeJustification, bool fromDatabase = false
 882    {
 1883        if (!includeJustification || prediction == null)
 884        {
 1885            return;
 886        }
 887
 1888        var sourceLabel = fromDatabase ? "stored prediction" : "model response";
 889
 1890        var justificationWriter = new JustificationConsoleWriter(_console);
 1891        justificationWriter.WriteJustification(
 1892            prediction.Justification,
 1893            "[dim]    ↳ Justification:[/]",
 1894            "        ",
 1895            $"[yellow]    ↳ No justification available for this {sourceLabel}[/]");
 1896    }
 897
 898    /// <summary>
 899    /// Strips display suffixes like " (kpi-context)" from context document names
 900    /// to get the actual document name used in the repository.
 901    /// </summary>
 902    /// <param name="displayName">The display name that may contain a suffix</param>
 903    /// <returns>The actual document name without any display suffix</returns>
 904    private static string StripDisplaySuffix(string displayName)
 905    {
 906        // Look for patterns like " (some-text)" at the end and remove them
 1907        var lastParenIndex = displayName.LastIndexOf(" (");
 1908        if (lastParenIndex > 0 && displayName.EndsWith(")"))
 909        {
 1910            return displayName.Substring(0, lastParenIndex);
 911        }
 1912        return displayName;
 913    }
 914}