< Summary

Information
Class: Orchestrator.Commands.Operations.RandomMatch.RandomMatchCommand
Assembly: Orchestrator
File(s): /home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Operations/RandomMatch/RandomMatchCommand.cs
Line coverage
93%
Covered lines: 193
Uncovered lines: 14
Coverable lines: 207
Total lines: 381
Line coverage: 93.2%
Branch coverage
77%
Covered branches: 37
Total branches: 48
Branch coverage: 77%
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()75%44100%
ExecuteRandomMatchWorkflow()77.78%181898.1%
GetMatchContextDocumentsAsync()100%88100%
GetHybridContextAsync()87.5%8896.3%
EnsureWorldCupRequiredContextPresent(...)0%2040%
WriteJustificationIfNeeded(...)83.33%66100%

File(s)

/home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Operations/RandomMatch/RandomMatchCommand.cs

#LineLine coverage
 1using System.Text.Json;
 2using Microsoft.Extensions.Logging;
 3using Spectre.Console.Cli;
 4using Spectre.Console;
 5using OpenAiIntegration;
 6using ContextProviders.Kicktipp;
 7using EHonda.KicktippAi.Core;
 8using Orchestrator.Commands.Shared;
 9using Orchestrator.Infrastructure;
 10using Orchestrator.Infrastructure.Factories;
 11using Orchestrator.Infrastructure.Langfuse;
 12
 13namespace Orchestrator.Commands.Operations.RandomMatch;
 14
 15public class RandomMatchCommand : AsyncCommand<RandomMatchSettings>
 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<RandomMatchCommand> _logger;
 23    private readonly ILangfusePublicApiClient? _langfuseClient;
 24
 125    public RandomMatchCommand(
 126        IAnsiConsole console,
 127        IFirebaseServiceFactory firebaseServiceFactory,
 128        IKicktippClientFactory kicktippClientFactory,
 129        IOpenAiServiceFactory openAiServiceFactory,
 130        IContextProviderFactory contextProviderFactory,
 131        ILogger<RandomMatchCommand> logger,
 132        ILangfusePublicApiClient? langfuseClient = null)
 33    {
 134        _console = console;
 135        _firebaseServiceFactory = firebaseServiceFactory;
 136        _kicktippClientFactory = kicktippClientFactory;
 137        _openAiServiceFactory = openAiServiceFactory;
 138        _contextProviderFactory = contextProviderFactory;
 139        _logger = logger;
 140        _langfuseClient = langfuseClient;
 141    }
 42
 43    protected override async Task<int> ExecuteAsync(CommandContext context, RandomMatchSettings settings, CancellationTo
 44    {
 45        try
 46        {
 147            var initialModel = string.IsNullOrWhiteSpace(settings.Model) ? "(competition default)" : settings.Model;
 148            _console.MarkupLine($"[green]Random match command initialized with model:[/] [yellow]{initialModel}[/]");
 49
 150            if (settings.WithJustification)
 51            {
 152                _console.MarkupLine("[green]Justification output enabled - model reasoning will be captured[/]");
 53            }
 54
 155            await ExecuteRandomMatchWorkflow(settings);
 56
 157            return 0;
 58        }
 159        catch (Exception ex)
 60        {
 161            _logger.LogError(ex, "Error executing random-match command");
 162            _console.MarkupLine($"[red]Error:[/] {ex.Message}");
 163            return 1;
 64        }
 165    }
 66
 67    private async Task ExecuteRandomMatchWorkflow(RandomMatchSettings settings)
 68    {
 69        // Start root OTel activity for Langfuse trace
 170        using var activity = Telemetry.Source.StartActivity("random-match");
 71
 72        // RandomMatch is always a development trace
 173        LangfuseActivityPropagation.SetEnvironment(activity, "development");
 74
 175        var communityContext = settings.CommunityContext ?? settings.Community;
 176        var competition = CompetitionResolver.ResolveCompetition(settings.Competition, settings.Community, communityCont
 177        var modelConfig = PredictionServiceCommandSupport.CreateModelConfig(settings.Model, settings.ReasoningEffort);
 178        var model = modelConfig.Model;
 179        var repositoryCompetition = CompetitionResolver.ToRepositoryCompetitionArgument(competition);
 80
 181        if (settings.WithJustification && PredictionServiceCommandSupport.UsesLangfusePromptSource(
 182                competition,
 183                settings.Community,
 184                communityContext,
 185                settings.PromptSource,
 186                bonusPrompt: false))
 87        {
 088            throw new NotSupportedException(
 089                "WM 2026 hosted match prompts with justification are not supported yet. Use local prompts or omit --with
 90        }
 91
 92        // Create services using factories
 193        var kicktippClient = _kicktippClientFactory.CreateClient();
 194        var predictionService = PredictionServiceCommandSupport.CreatePredictionService(
 195            _openAiServiceFactory,
 196            _langfuseClient,
 197            _console,
 198            model,
 199            competition,
 1100            settings.Community,
 1101            communityContext,
 1102            settings.PromptSource,
 1103            settings.LangfusePromptName,
 1104            settings.LangfusePromptLabel,
 1105            settings.LangfusePromptVersion,
 1106            modelConfig.ReasoningEffort,
 1107            maxOutputTokenCount: null,
 1108            bonusPrompt: false);
 109
 110        // Create context provider using factory (community is used as context)
 1111        var contextProvider = _contextProviderFactory.CreateKicktippContextProvider(
 1112            kicktippClient, settings.Community, communityContext, repositoryCompetition);
 113
 1114        var tokenUsageTracker = _openAiServiceFactory.GetTokenUsageTracker();
 115
 1116        _console.MarkupLine($"[dim]Match prompt:[/] [blue]{predictionService.GetMatchPromptPath(settings.WithJustificati
 117
 118        // Create context repository for hybrid context lookup
 1119        var contextRepository = _firebaseServiceFactory.CreateContextRepository(repositoryCompetition);
 120
 121        // Reset token usage tracker for this workflow
 1122        tokenUsageTracker.Reset();
 123
 1124        _console.MarkupLine($"[blue]Using community:[/] [yellow]{settings.Community}[/]");
 1125        _console.MarkupLine($"[blue]Using community context:[/] [yellow]{communityContext}[/]");
 1126        _console.MarkupLine($"[blue]Using competition:[/] [yellow]{competition}[/]");
 1127        _console.MarkupLine("[blue]Getting current matchday matches...[/]");
 128
 129        // Step 1: Get current matchday matches
 1130        var matchesWithHistory = await kicktippClient.GetMatchesWithHistoryAsync(settings.Community);
 131
 1132        if (!matchesWithHistory.Any())
 133        {
 1134            _console.MarkupLine("[yellow]No matches found for current matchday[/]");
 1135            return;
 136        }
 137
 138        // Step 2: Pick a random match
 1139        var randomIndex = Random.Shared.Next(matchesWithHistory.Count);
 1140        var selectedMatchWithHistory = matchesWithHistory[randomIndex];
 1141        var match = selectedMatchWithHistory.Match;
 142
 1143        _console.MarkupLine($"[green]Found {matchesWithHistory.Count} matches for current matchday[/]");
 1144        _console.MarkupLine($"[cyan]Randomly selected match {randomIndex + 1}/{matchesWithHistory.Count}:[/] [yellow]{ma
 145
 146        // Set Langfuse trace-level attributes
 1147        var matchday = match.Matchday;
 1148        var sessionId = $"random-match-{matchday}-{settings.Community}";
 1149        var traceTags = new[] { settings.Community, model, competition, "random-match" };
 1150        LangfuseActivityPropagation.SetSessionId(activity, sessionId);
 1151        LangfuseActivityPropagation.SetTraceTags(activity, traceTags);
 1152        LangfuseActivityPropagation.SetTraceMetadata(activity, "community", settings.Community);
 1153        LangfuseActivityPropagation.SetTraceMetadata(activity, "communityContext", communityContext);
 1154        LangfuseActivityPropagation.SetTraceMetadata(activity, "competition", competition);
 1155        LangfuseActivityPropagation.SetTraceMetadata(activity, "matchday", matchday.ToString());
 1156        LangfuseActivityPropagation.SetTraceMetadata(activity, "model", model);
 1157        LangfuseActivityPropagation.SetTraceMetadata(activity, "homeTeams", PredictionTelemetryMetadata.BuildDelimitedFi
 1158        LangfuseActivityPropagation.SetTraceMetadata(activity, "awayTeams", PredictionTelemetryMetadata.BuildDelimitedFi
 1159        LangfuseActivityPropagation.SetTraceMetadata(activity, "teams", PredictionTelemetryMetadata.BuildDelimitedFilter
 1160        LangfuseActivityPropagation.SetTraceMetadata(activity, "selectedMatch", $"{match.HomeTeam} vs {match.AwayTeam}",
 161
 162        // Set trace input
 1163        var traceInput = new
 1164        {
 1165            community = settings.Community,
 1166            matchday,
 1167            model,
 1168            competition,
 1169            match = $"{match.HomeTeam} vs {match.AwayTeam}"
 1170        };
 1171        activity?.SetTag("langfuse.trace.input", JsonSerializer.Serialize(traceInput));
 172
 1173        _console.MarkupLine($"[dim]Matchday: {matchday}[/]");
 174
 1175        if (match.IsCancelled)
 176        {
 1177            _console.MarkupLine($"[yellow]  ⚠ {match.HomeTeam} vs {match.AwayTeam} is cancelled (Abgesagt). " +
 1178                $"Processing with inherited time - prediction may need re-evaluation when rescheduled.[/]");
 179        }
 180
 181        // Step 3: Generate prediction
 1182        _console.MarkupLine($"[yellow]  → Generating prediction...[/]");
 183
 184        // Get context using hybrid approach (database first, fallback to on-demand)
 1185        var contextDocuments = await GetHybridContextAsync(
 1186            contextRepository,
 1187            contextProvider,
 1188            match.HomeTeam,
 1189            match.AwayTeam,
 1190            communityContext,
 1191            competition);
 192
 1193        _console.MarkupLine($"[dim]    Using {contextDocuments.Count} context documents[/]");
 194
 195        // Predict the match
 1196        var telemetryMetadata = new PredictionTelemetryMetadata(
 1197            HomeTeam: match.HomeTeam,
 1198            AwayTeam: match.AwayTeam,
 1199            RepredictionIndex: 0);
 200
 1201        var prediction = await predictionService.PredictMatchAsync(match, contextDocuments, settings.WithJustification, 
 202
 1203        if (prediction != null)
 204        {
 1205            _console.MarkupLine($"[green]  ✓ Prediction:[/] {prediction.HomeGoals}:{prediction.AwayGoals}");
 1206            WriteJustificationIfNeeded(prediction, settings.WithJustification);
 207
 208            // Set trace output
 1209            var traceOutput = new
 1210            {
 1211                match = $"{match.HomeTeam} vs {match.AwayTeam}",
 1212                prediction = $"{prediction.HomeGoals}:{prediction.AwayGoals}"
 1213            };
 1214            activity?.SetTag("langfuse.trace.output", JsonSerializer.Serialize(traceOutput));
 215        }
 216        else
 217        {
 1218            _console.MarkupLine($"[red]  ✗ Failed to generate prediction[/]");
 1219            activity?.SetTag("langfuse.trace.output", JsonSerializer.Serialize(new { error = "Failed to generate predict
 220        }
 221
 222        // Display token usage summary
 1223        var summary = tokenUsageTracker.GetCompactSummary();
 1224        _console.MarkupLine($"[dim]Token usage (uncached/cached/reasoning/output/$cost): {summary}[/]");
 1225    }
 226
 227    /// <summary>
 228    /// Retrieves all available context documents from the database for the given community context.
 229    /// </summary>
 230    private async Task<Dictionary<string, DocumentContext>> GetMatchContextDocumentsAsync(
 231        IContextRepository contextRepository,
 232        string homeTeam,
 233        string awayTeam,
 234        string communityContext,
 235        string competition)
 236    {
 1237        var contextDocuments = new Dictionary<string, DocumentContext>();
 1238        var selection = MatchContextDocumentCatalog.ForMatch(homeTeam, awayTeam, communityContext, competition);
 239
 1240        _console.MarkupLine($"[dim]    Looking for {selection.RequiredDocumentNames.Count} specific context documents in
 241
 242        try
 243        {
 1244            foreach (var documentName in selection.RequiredDocumentNames)
 245            {
 1246                var contextDoc = await contextRepository.GetLatestContextDocumentAsync(documentName, communityContext);
 1247                if (contextDoc != null)
 248                {
 1249                    contextDocuments[documentName] = new DocumentContext(contextDoc.DocumentName, contextDoc.Content);
 1250                    _console.MarkupLine($"[dim]      ✓ Retrieved {documentName} (version {contextDoc.Version})[/]");
 251                }
 252                else
 253                {
 1254                    _console.MarkupLine($"[dim]      ✗ Missing {documentName}[/]");
 255                }
 1256            }
 257
 1258            foreach (var documentName in selection.OptionalDocumentNames)
 259            {
 260                try
 261                {
 1262                    var contextDoc = await contextRepository.GetLatestContextDocumentAsync(documentName, communityContex
 1263                    if (contextDoc != null)
 264                    {
 1265                        contextDocuments[documentName] = new DocumentContext(contextDoc.DocumentName, contextDoc.Content
 1266                        _console.MarkupLine($"[dim]      ✓ Retrieved optional {documentName} (version {contextDoc.Versio
 267                    }
 268                    else
 269                    {
 1270                        _console.MarkupLine($"[dim]      · Missing optional {documentName}[/]");
 271                    }
 1272                }
 1273                catch (Exception optEx)
 274                {
 1275                    _console.MarkupLine($"[dim]      · Failed optional {documentName}: {optEx.Message}[/]");
 1276                }
 1277            }
 1278        }
 1279        catch (Exception ex)
 280        {
 1281            _console.MarkupLine($"[red]    Warning: Failed to retrieve context from database: {ex.Message}[/]");
 1282        }
 283
 1284        return contextDocuments;
 1285    }
 286
 287    /// <summary>
 288    /// Gets context documents using database first, falling back to on-demand context provider if needed.
 289    /// </summary>
 290    private async Task<List<DocumentContext>> GetHybridContextAsync(
 291        IContextRepository contextRepository,
 292        IKicktippContextProvider contextProvider,
 293        string homeTeam,
 294        string awayTeam,
 295        string communityContext,
 296        string competition)
 297    {
 1298        var contextDocuments = new List<DocumentContext>();
 1299        var databaseContexts = await GetMatchContextDocumentsAsync(
 1300            contextRepository,
 1301            homeTeam,
 1302            awayTeam,
 1303            communityContext,
 1304            competition);
 305
 1306        var requiredDocuments = MatchContextDocumentCatalog
 1307            .ForMatch(homeTeam, awayTeam, communityContext, competition)
 1308            .RequiredDocumentNames;
 309
 1310        int requiredPresent = requiredDocuments.Count(d => databaseContexts.ContainsKey(d));
 1311        int requiredTotal = requiredDocuments.Count;
 312
 1313        if (requiredPresent == requiredTotal)
 314        {
 1315            _console.MarkupLine($"[green]    Using {databaseContexts.Count} context documents from database (all require
 1316            contextDocuments.AddRange(databaseContexts.Values);
 317        }
 318        else
 319        {
 1320            _console.MarkupLine($"[yellow]    Warning: Only found {requiredPresent}/{requiredTotal} required context doc
 321
 1322            contextDocuments.AddRange(databaseContexts.Values);
 323
 1324            var existingNames = new HashSet<string>(contextDocuments.Select(c => c.Name), StringComparer.OrdinalIgnoreCa
 1325            await foreach (var context in contextProvider.GetMatchContextAsync(homeTeam, awayTeam))
 326            {
 1327                if (existingNames.Add(context.Name))
 328                {
 1329                    contextDocuments.Add(context);
 330                }
 331            }
 332
 1333            _console.MarkupLine($"[yellow]    Using {contextDocuments.Count} merged context documents (database + on-dem
 1334        }
 335
 1336        if (CompetitionResolver.IsWorldCupCompetition(competition))
 337        {
 0338            EnsureWorldCupRequiredContextPresent(contextDocuments, requiredDocuments);
 339        }
 340
 1341        return contextDocuments;
 1342    }
 343
 344    private static void EnsureWorldCupRequiredContextPresent(
 345        IReadOnlyList<DocumentContext> contextDocuments,
 346        IReadOnlyList<string> requiredDocuments)
 347    {
 0348        var presentDocumentNames = new HashSet<string>(
 0349            contextDocuments.Select(document => document.Name),
 0350            StringComparer.OrdinalIgnoreCase);
 0351        var missingDocumentNames = requiredDocuments
 0352            .Where(documentName => !presentDocumentNames.Contains(documentName))
 0353            .ToList();
 354
 0355        if (missingDocumentNames.Count == 0)
 356        {
 0357            return;
 358        }
 359
 0360        throw new InvalidOperationException(
 0361            "Missing required WM26 context documents after database and on-demand fallback: " +
 0362            $"{string.Join(", ", missingDocumentNames)}. Seed FIFA rankings with collect-context fifa and lineups with c
 363    }
 364
 365    private void WriteJustificationIfNeeded(Prediction? prediction, bool includeJustification, bool fromDatabase = false
 366    {
 1367        if (!includeJustification || prediction == null)
 368        {
 1369            return;
 370        }
 371
 1372        var sourceLabel = fromDatabase ? "stored prediction" : "model response";
 373
 1374        var justificationWriter = new JustificationConsoleWriter(_console);
 1375        justificationWriter.WriteJustification(
 1376            prediction.Justification,
 1377            "[dim]    ↳ Justification:[/]",
 1378            "        ",
 1379            $"[yellow]    ↳ No justification available for this {sourceLabel}[/]");
 1380    }
 381}