< Summary

Information
Class: Orchestrator.Commands.Operations.Bonus.BonusCommand
Assembly: Orchestrator
File(s): /home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Operations/Bonus/BonusCommand.cs
Line coverage
100%
Covered lines: 224
Uncovered lines: 0
Coverable lines: 224
Total lines: 462
Line coverage: 100%
Branch coverage
95%
Covered branches: 133
Total branches: 140
Branch coverage: 95%
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%2626100%
.cctor()100%11100%
ExecuteBonusWorkflow()93.86%114114100%

File(s)

/home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Operations/Bonus/BonusCommand.cs

#LineLine coverage
 1using System.Text.Json;
 2using EHonda.KicktippAi.Core;
 3using Microsoft.Extensions.Logging;
 4using Spectre.Console.Cli;
 5using Spectre.Console;
 6using OpenAiIntegration;
 7using Orchestrator.Commands.Operations.Matchday;
 8using Orchestrator.Commands.Shared;
 9using Orchestrator.Infrastructure;
 10using Orchestrator.Infrastructure.Factories;
 11
 12namespace Orchestrator.Commands.Operations.Bonus;
 13
 14public class BonusCommand : AsyncCommand<BaseSettings>
 15{
 16    private readonly IAnsiConsole _console;
 17    private readonly IFirebaseServiceFactory _firebaseServiceFactory;
 18    private readonly IKicktippClientFactory _kicktippClientFactory;
 19    private readonly IOpenAiServiceFactory _openAiServiceFactory;
 20    private readonly IContextProviderFactory _contextProviderFactory;
 21    private readonly ILogger<BonusCommand> _logger;
 22
 123    public BonusCommand(
 124        IAnsiConsole console,
 125        IFirebaseServiceFactory firebaseServiceFactory,
 126        IKicktippClientFactory kicktippClientFactory,
 127        IOpenAiServiceFactory openAiServiceFactory,
 128        IContextProviderFactory contextProviderFactory,
 129        ILogger<BonusCommand> logger)
 30    {
 131        _console = console;
 132        _firebaseServiceFactory = firebaseServiceFactory;
 133        _kicktippClientFactory = kicktippClientFactory;
 134        _openAiServiceFactory = openAiServiceFactory;
 135        _contextProviderFactory = contextProviderFactory;
 136        _logger = logger;
 137    }
 38
 39    public override async Task<int> ExecuteAsync(CommandContext context, BaseSettings settings)
 40    {
 41
 42        try
 43        {
 144            _console.MarkupLine($"[green]Bonus command initialized with model:[/] [yellow]{settings.Model}[/]");
 45
 146            if (settings.Verbose)
 47            {
 148                _console.MarkupLine("[dim]Verbose mode enabled[/]");
 49            }
 50
 151            if (settings.OverrideKicktipp)
 52            {
 153                _console.MarkupLine("[yellow]Override mode enabled - will override existing Kicktipp predictions[/]");
 54            }
 55
 156            if (settings.OverrideDatabase)
 57            {
 158                _console.MarkupLine("[yellow]Override database mode enabled - will override existing database prediction
 59            }
 60
 161            if (settings.Agent)
 62            {
 163                _console.MarkupLine("[blue]Agent mode enabled - prediction details will be hidden[/]");
 64            }
 65
 166            if (settings.DryRun)
 67            {
 168                _console.MarkupLine("[magenta]Dry run mode enabled - no changes will be made to database or Kicktipp[/]"
 69            }
 70
 171            if (!string.IsNullOrEmpty(settings.EstimatedCostsModel))
 72            {
 173                _console.MarkupLine($"[cyan]Estimated costs will be calculated for model:[/] [yellow]{settings.Estimated
 74            }
 75
 76            // Validate reprediction settings
 177            if (settings.OverrideDatabase && settings.IsRepredictMode)
 78            {
 179                _console.MarkupLine($"[red]Error:[/] --override-database cannot be used with reprediction flags (--repre
 180                return 1;
 81            }
 82
 183            if (settings.MaxRepredictions.HasValue && settings.MaxRepredictions.Value < 0)
 84            {
 185                _console.MarkupLine($"[red]Error:[/] --max-repredictions must be 0 or greater");
 186                return 1;
 87            }
 88
 189            if (settings.IsRepredictMode)
 90            {
 191                var maxValue = settings.MaxRepredictions ?? int.MaxValue;
 192                _console.MarkupLine($"[yellow]Reprediction mode enabled - max repredictions: {(settings.MaxRepredictions
 93            }
 94
 95            // Execute the bonus prediction workflow
 196            await ExecuteBonusWorkflow(settings);
 97
 198            return 0;
 99        }
 1100        catch (Exception ex)
 101        {
 1102            _logger.LogError(ex, "Error executing bonus command");
 1103            _console.MarkupLine($"[red]Error:[/] {ex.Message}");
 1104            return 1;
 105        }
 1106    }
 107
 108    /// <summary>
 109    /// Communities that have production workflows invoking the bonus command.
 110    /// Update this set when adding or removing community bonus workflows in .github/workflows/.
 111    /// See .github/workflows/AGENTS.md for details.
 112    /// </summary>
 1113    private static readonly HashSet<string> ProductionCommunities = new(StringComparer.OrdinalIgnoreCase)
 1114    {
 1115        "schadensfresse",
 1116        "pes-squad",
 1117        "ehonda-ai-arena"
 1118    };
 119
 120    private async Task ExecuteBonusWorkflow(BaseSettings settings)
 121    {
 122        // Start root OTel activity for Langfuse trace
 1123        using var activity = Telemetry.Source.StartActivity("bonus");
 124
 125        // Set Langfuse environment based on community
 1126        var environment = ProductionCommunities.Contains(settings.Community) ? "production" : "development";
 1127        LangfuseActivityPropagation.SetEnvironment(activity, environment);
 128
 129        // Set Langfuse trace-level attributes
 1130        var sessionId = $"bonus-{settings.Community}";
 1131        var traceTags = new[] { settings.Community, settings.Model };
 1132        LangfuseActivityPropagation.SetSessionId(activity, sessionId);
 1133        LangfuseActivityPropagation.SetTraceTags(activity, traceTags);
 1134        LangfuseActivityPropagation.SetTraceMetadata(activity, "community", settings.Community);
 1135        LangfuseActivityPropagation.SetTraceMetadata(activity, "kicktipp-season", KicktippSeasonMetadata.Current);
 1136        LangfuseActivityPropagation.SetTraceMetadata(activity, "model", settings.Model);
 1137        LangfuseActivityPropagation.SetTraceMetadata(activity, "repredictMode", settings.IsRepredictMode ? "true" : "fal
 138
 139        // Note: trace input is set after bonus questions are fetched
 140
 141        // Create services using factories
 1142        var kicktippClient = _kicktippClientFactory.CreateClient();
 1143        var predictionService = _openAiServiceFactory.CreatePredictionService(settings.Model);
 144
 145        // Log the prompt paths being used
 1146        if (settings.Verbose)
 147        {
 1148            _console.MarkupLine($"[dim]Bonus prompt:[/] [blue]{predictionService.GetBonusPromptPath()}[/]");
 149        }
 150
 151        // Create KPI Context Provider for bonus predictions using factory
 1152        var kpiContextProvider = _contextProviderFactory.CreateKpiContextProvider();
 153
 1154        var tokenUsageTracker = _openAiServiceFactory.GetTokenUsageTracker();
 155
 156        // Create prediction repository
 1157        var predictionRepository = _firebaseServiceFactory.CreatePredictionRepository();
 1158        var databaseEnabled = true;
 159
 160        // Reset token usage tracker for this workflow
 1161        tokenUsageTracker.Reset();
 162
 163        // Determine community context (use explicit setting or fall back to community name)
 1164        string communityContext = settings.CommunityContext ?? settings.Community;
 1165        LangfuseActivityPropagation.SetTraceMetadata(activity, "communityContext", communityContext);
 166
 1167        _console.MarkupLine($"[blue]Using community:[/] [yellow]{settings.Community}[/]");
 1168        _console.MarkupLine($"[blue]Using community context:[/] [yellow]{communityContext}[/]");
 1169        _console.MarkupLine("[blue]Getting open bonus questions from Kicktipp...[/]");
 170
 171        // Step 1: Get open bonus questions from Kicktipp
 1172        var bonusQuestions = await kicktippClient.GetOpenBonusQuestionsAsync(settings.Community);
 173
 1174        if (!bonusQuestions.Any())
 175        {
 1176            _console.MarkupLine("[yellow]No open bonus questions found[/]");
 1177            return;
 178        }
 179
 1180        _console.MarkupLine($"[green]Found {bonusQuestions.Count} open bonus questions[/]");
 181
 182        // Set trace input now that we know the questions
 1183        var traceInput = new
 1184        {
 1185            community = settings.Community,
 1186            model = settings.Model,
 1187            questions = bonusQuestions.Select(q => q.Text).ToArray()
 1188        };
 1189        activity?.SetTag("langfuse.trace.input", JsonSerializer.Serialize(traceInput));
 190
 1191        if (databaseEnabled)
 192        {
 1193            _console.MarkupLine("[blue]Database enabled - checking for existing predictions...[/]");
 194        }
 195
 1196        var predictions = new Dictionary<string, BonusPrediction>();
 1197        var traceRepredictionIndices = new HashSet<string>(StringComparer.Ordinal);
 198
 199        // Step 2: For each question, check database first, then predict if needed
 1200        foreach (var question in bonusQuestions)
 201        {
 1202            _console.MarkupLine($"[cyan]Processing:[/] {Markup.Escape(question.Text)}");
 203
 204            try
 205            {
 1206                BonusPrediction? prediction = null;
 1207                bool fromDatabase = false;
 1208                bool shouldPredict = false;
 1209                int? predictionRepredictionIndex = settings.IsRepredictMode ? null : 0;
 210
 211                // Check if we have an existing prediction in the database
 1212                if (databaseEnabled && !settings.OverrideDatabase && !settings.IsRepredictMode)
 213                {
 214                    // Look for prediction by question text, model, and community context
 1215                    prediction = await predictionRepository!.GetBonusPredictionByTextAsync(question.Text, settings.Model
 1216                    if (prediction != null)
 217                    {
 1218                        fromDatabase = true;
 1219                        if (settings.Agent)
 220                        {
 1221                            _console.MarkupLine($"[green]  ✓ Found existing prediction[/] [dim](from database)[/]");
 222                        }
 223                        else
 224                        {
 1225                            var optionTexts = question.Options
 1226                                .Where(o => prediction.SelectedOptionIds.Contains(o.Id))
 1227                                .Select(o => o.Text);
 1228                            _console.MarkupLine($"[green]  ✓ Found existing prediction:[/] {string.Join(", ", optionText
 229                        }
 230                    }
 231                }
 232
 233                // Handle reprediction logic
 1234                if (settings.IsRepredictMode && databaseEnabled)
 235                {
 1236                    var currentRepredictionIndex = await predictionRepository!.GetBonusRepredictionIndexAsync(question.T
 237
 1238                    if (currentRepredictionIndex == -1)
 239                    {
 240                        // No prediction exists yet - create first prediction
 1241                        shouldPredict = true;
 1242                        predictionRepredictionIndex = 0;
 1243                        _console.MarkupLine($"[yellow]  → No existing prediction found, creating first prediction...[/]"
 244                    }
 245                    else
 246                    {
 247                        // Check if we can create another reprediction
 1248                        var maxAllowed = settings.MaxRepredictions ?? int.MaxValue;
 1249                        var nextIndex = currentRepredictionIndex + 1;
 250
 1251                        if (nextIndex <= maxAllowed)
 252                        {
 1253                            shouldPredict = true;
 1254                            predictionRepredictionIndex = nextIndex;
 1255                            _console.MarkupLine($"[yellow]  → Creating reprediction {nextIndex} (current: {currentRepred
 256                        }
 257                        else
 258                        {
 1259                            traceRepredictionIndices.Add(currentRepredictionIndex.ToString());
 1260                            _console.MarkupLine($"[yellow]  ✗ Skipped - already at max repredictions ({currentRepredicti
 261
 262                            // Get the latest prediction for display purposes
 1263                            prediction = await predictionRepository!.GetBonusPredictionByTextAsync(question.Text, settin
 1264                            if (prediction != null)
 265                            {
 1266                                fromDatabase = true;
 1267                                if (!settings.Agent)
 268                                {
 1269                                    var optionTexts = question.Options
 1270                                        .Where(o => prediction.SelectedOptionIds.Contains(o.Id))
 1271                                        .Select(o => o.Text);
 1272                                    _console.MarkupLine($"[green]  ✓ Latest prediction:[/] {string.Join(", ", optionText
 273                                }
 274                            }
 275                        }
 276                    }
 277                }
 278
 279                // If no existing prediction (normal mode) or we need to predict (reprediction mode), generate a new one
 1280                if (prediction == null || shouldPredict)
 281                {
 1282                    _console.MarkupLine($"[yellow]  → Generating new prediction...[/]");
 283
 284                    // Step 3: Get KPI context for bonus predictions
 1285                    var contextDocuments = new List<DocumentContext>();
 286
 287                    // Use KPI documents as context for bonus predictions (targeted by question content)
 1288                    await foreach (var context in kpiContextProvider.GetBonusQuestionContextAsync(question.Text, communi
 289                    {
 1290                        contextDocuments.Add(context);
 291                    }
 292
 1293                    if (settings.Verbose)
 294                    {
 1295                        _console.MarkupLine($"[dim]    Using {contextDocuments.Count} KPI context documents[/]");
 296                    }
 297
 1298                    var telemetryMetadata = new PredictionTelemetryMetadata(
 1299                        RepredictionIndex: predictionRepredictionIndex);
 300
 301                    // Predict the bonus question
 1302                    prediction = await predictionService.PredictBonusQuestionAsync(question, contextDocuments, telemetry
 303
 1304                    if (prediction != null)
 305                    {
 1306                        if (predictionRepredictionIndex.HasValue)
 307                        {
 1308                            traceRepredictionIndices.Add(predictionRepredictionIndex.Value.ToString());
 309                        }
 310
 1311                        if (settings.Agent)
 312                        {
 1313                            _console.MarkupLine($"[green]  ✓ Generated prediction[/]");
 314                        }
 315                        else
 316                        {
 1317                            var optionTexts = question.Options
 1318                                .Where(o => prediction.SelectedOptionIds.Contains(o.Id))
 1319                                .Select(o => o.Text);
 1320                            _console.MarkupLine($"[green]  ✓ Generated prediction:[/] {string.Join(", ", optionTexts)}")
 321                        }
 322
 323                        // Save to database immediately if enabled
 1324                        if (databaseEnabled && !settings.DryRun)
 325                        {
 326                            try
 327                            {
 328                                // Get token usage and cost information
 1329                                var cost = (double)tokenUsageTracker.GetLastCost(); // Get the cost for this individual 
 330                                // Use the new GetLastUsageJson method to get full JSON
 1331                                var tokenUsageJson = tokenUsageTracker.GetLastUsageJson() ?? "{}";
 332
 1333                                if (settings.IsRepredictMode)
 334                                {
 335                                    // Save as reprediction with specific index
 1336                                    var currentIndex = await predictionRepository!.GetBonusRepredictionIndexAsync(questi
 1337                                    var nextIndex = currentIndex == -1 ? 0 : currentIndex + 1;
 338
 1339                                    await predictionRepository!.SaveBonusRepredictionAsync(
 1340                                        question,
 1341                                        prediction,
 1342                                        settings.Model,
 1343                                        tokenUsageJson,
 1344                                        cost,
 1345                                        communityContext,
 1346                                        contextDocuments.Select(d => d.Name),
 1347                                        nextIndex);
 348
 1349                                    if (settings.Verbose)
 350                                    {
 1351                                        _console.MarkupLine($"[dim]    ✓ Saved as reprediction {nextIndex} to database[/
 352                                    }
 353                                }
 354                                else
 355                                {
 356                                    // Save normally (override or new prediction)
 1357                                    await predictionRepository!.SaveBonusPredictionAsync(
 1358                                        question,
 1359                                        prediction,
 1360                                        settings.Model,
 1361                                        tokenUsageJson,
 1362                                        cost,
 1363                                        communityContext,
 1364                                        contextDocuments.Select(d => d.Name),
 1365                                        overrideCreatedAt: settings.OverrideDatabase);
 366
 1367                                    if (settings.Verbose)
 368                                    {
 1369                                        _console.MarkupLine($"[dim]    ✓ Saved to database[/]");
 370                                    }
 371                                }
 1372                            }
 1373                            catch (Exception ex)
 374                            {
 1375                                _logger.LogError(ex, "Failed to save bonus prediction for question '{QuestionText}'", qu
 1376                                _console.MarkupLine($"[red]    ✗ Failed to save to database: {ex.Message}[/]");
 1377                            }
 378                        }
 1379                        else if (databaseEnabled && settings.DryRun && settings.Verbose)
 380                        {
 1381                            _console.MarkupLine($"[dim]    (Dry run - skipped database save)[/]");
 382                        }
 383
 384                        // Show individual question token usage in verbose mode
 1385                        if (settings.Verbose)
 386                        {
 1387                            var questionUsage = !string.IsNullOrEmpty(settings.EstimatedCostsModel)
 1388                                ? tokenUsageTracker.GetLastUsageCompactSummaryWithEstimatedCosts(settings.EstimatedCosts
 1389                                : tokenUsageTracker.GetLastUsageCompactSummary();
 1390                            _console.MarkupLine($"[dim]    Token usage: {questionUsage}[/]");
 391                        }
 392                    }
 393                    else
 394                    {
 1395                        _console.MarkupLine($"[red]  ✗ Failed to generate prediction[/]");
 1396                        continue;
 397                    }
 1398                }
 399
 1400                predictions[question.FormFieldName ?? question.Text] = prediction;
 401
 1402                if (!fromDatabase && settings.Verbose)
 403                {
 1404                    _console.MarkupLine($"[dim]    Ready for Kicktipp placement[/]");
 405                }
 1406            }
 1407            catch (Exception ex)
 408            {
 1409                _logger.LogError(ex, "Error processing bonus question '{QuestionText}'", question.Text);
 1410                _console.MarkupLine($"[red]  ✗ Error processing question: {ex.Message}[/]");
 1411            }
 1412        }
 413
 1414        if (traceRepredictionIndices.Count > 0)
 415        {
 1416            LangfuseActivityPropagation.SetTraceMetadata(activity, "repredictionIndices", PredictionTelemetryMetadata.Bu
 1417            LangfuseActivityPropagation.SetTraceMetadata(activity, "hasRepredictions", traceRepredictionIndices.Any(inde
 418        }
 419
 1420        if (!predictions.Any())
 421        {
 1422            _console.MarkupLine("[yellow]No predictions available, nothing to place[/]");
 1423            activity?.SetTag("langfuse.trace.output", JsonSerializer.Serialize(new { error = "No predictions available" 
 1424            return;
 425        }
 426
 427        // Set trace output with all bonus predictions
 1428        var traceOutput = predictions.Select(p => new
 1429        {
 1430            question = p.Key,
 1431            selectedOptionIds = p.Value.SelectedOptionIds
 1432        }).ToArray();
 1433        activity?.SetTag("langfuse.trace.output", JsonSerializer.Serialize(traceOutput));
 434
 435        // Step 4: Place all predictions using PlaceBonusPredictionsAsync
 1436        _console.MarkupLine($"[blue]Placing {predictions.Count} bonus predictions to Kicktipp...[/]");
 437
 1438        if (settings.DryRun)
 439        {
 1440            _console.MarkupLine($"[magenta]✓ Dry run mode - would have placed {predictions.Count} bonus predictions (no 
 441        }
 442        else
 443        {
 1444            var success = await kicktippClient.PlaceBonusPredictionsAsync(settings.Community, predictions, overridePredi
 445
 1446            if (success)
 447            {
 1448                _console.MarkupLine($"[green]✓ Successfully placed all {predictions.Count} bonus predictions![/]");
 449            }
 450            else
 451            {
 1452                _console.MarkupLine("[red]✗ Failed to place some or all bonus predictions[/]");
 453            }
 454        }
 455
 456        // Display token usage summary
 1457        var summary = !string.IsNullOrEmpty(settings.EstimatedCostsModel)
 1458            ? tokenUsageTracker.GetCompactSummaryWithEstimatedCosts(settings.EstimatedCostsModel)
 1459            : tokenUsageTracker.GetCompactSummary();
 1460        _console.MarkupLine($"[dim]Token usage (uncached/cached/reasoning/output/$cost): {summary}[/]");
 1461    }
 462}