< 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
100%
Covered lines: 212
Uncovered lines: 0
Coverable lines: 212
Total lines: 384
Line coverage: 100%
Branch coverage
93%
Covered branches: 41
Total branches: 44
Branch coverage: 93.1%
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%22100%
ExecuteRandomMatchWorkflow()91.67%1212100%
GetMatchContextDocumentsAsync()100%88100%
GetHybridContextAsync()100%66100%
GetTeamAbbreviation(...)90%1010100%
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;
 11
 12namespace Orchestrator.Commands.Operations.RandomMatch;
 13
 14public class RandomMatchCommand : AsyncCommand<RandomMatchSettings>
 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<RandomMatchCommand> _logger;
 22
 123    public RandomMatchCommand(
 124        IAnsiConsole console,
 125        IFirebaseServiceFactory firebaseServiceFactory,
 126        IKicktippClientFactory kicktippClientFactory,
 127        IOpenAiServiceFactory openAiServiceFactory,
 128        IContextProviderFactory contextProviderFactory,
 129        ILogger<RandomMatchCommand> 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, RandomMatchSettings settings)
 40    {
 41        try
 42        {
 143            _console.MarkupLine($"[green]Random match command initialized with model:[/] [yellow]{settings.Model}[/]");
 44
 145            if (settings.WithJustification)
 46            {
 147                _console.MarkupLine("[green]Justification output enabled - model reasoning will be captured[/]");
 48            }
 49
 150            await ExecuteRandomMatchWorkflow(settings);
 51
 152            return 0;
 53        }
 154        catch (Exception ex)
 55        {
 156            _logger.LogError(ex, "Error executing random-match command");
 157            _console.MarkupLine($"[red]Error:[/] {ex.Message}");
 158            return 1;
 59        }
 160    }
 61
 62    private async Task ExecuteRandomMatchWorkflow(RandomMatchSettings settings)
 63    {
 64        // Start root OTel activity for Langfuse trace
 165        using var activity = Telemetry.Source.StartActivity("random-match");
 66
 67        // RandomMatch is always a development trace
 168        LangfuseActivityPropagation.SetEnvironment(activity, "development");
 69
 70        // Create services using factories
 171        var kicktippClient = _kicktippClientFactory.CreateClient();
 172        var predictionService = _openAiServiceFactory.CreatePredictionService(settings.Model);
 73
 74        // Create context provider using factory (community is used as context)
 175        var contextProvider = _contextProviderFactory.CreateKicktippContextProvider(
 176            kicktippClient, settings.Community, settings.Community);
 77
 178        var tokenUsageTracker = _openAiServiceFactory.GetTokenUsageTracker();
 79
 180        _console.MarkupLine($"[dim]Match prompt:[/] [blue]{predictionService.GetMatchPromptPath(settings.WithJustificati
 81
 82        // Create context repository for hybrid context lookup
 183        var contextRepository = _firebaseServiceFactory.CreateContextRepository();
 84
 85        // Reset token usage tracker for this workflow
 186        tokenUsageTracker.Reset();
 87
 188        _console.MarkupLine($"[blue]Using community:[/] [yellow]{settings.Community}[/]");
 189        _console.MarkupLine("[blue]Getting current matchday matches...[/]");
 90
 91        // Step 1: Get current matchday matches
 192        var matchesWithHistory = await kicktippClient.GetMatchesWithHistoryAsync(settings.Community);
 93
 194        if (!matchesWithHistory.Any())
 95        {
 196            _console.MarkupLine("[yellow]No matches found for current matchday[/]");
 197            return;
 98        }
 99
 100        // Step 2: Pick a random match
 1101        var randomIndex = Random.Shared.Next(matchesWithHistory.Count);
 1102        var selectedMatchWithHistory = matchesWithHistory[randomIndex];
 1103        var match = selectedMatchWithHistory.Match;
 104
 1105        _console.MarkupLine($"[green]Found {matchesWithHistory.Count} matches for current matchday[/]");
 1106        _console.MarkupLine($"[cyan]Randomly selected match {randomIndex + 1}/{matchesWithHistory.Count}:[/] [yellow]{ma
 107
 108        // Set Langfuse trace-level attributes
 1109        var matchday = match.Matchday;
 1110        var sessionId = $"random-match-{matchday}-{settings.Community}";
 1111        var traceTags = new[] { settings.Community, settings.Model, "random-match" };
 1112        LangfuseActivityPropagation.SetSessionId(activity, sessionId);
 1113        LangfuseActivityPropagation.SetTraceTags(activity, traceTags);
 1114        LangfuseActivityPropagation.SetTraceMetadata(activity, "community", settings.Community);
 1115        LangfuseActivityPropagation.SetTraceMetadata(activity, "kicktipp-season", KicktippSeasonMetadata.Current);
 1116        LangfuseActivityPropagation.SetTraceMetadata(activity, "matchday", matchday.ToString());
 1117        LangfuseActivityPropagation.SetTraceMetadata(activity, "model", settings.Model);
 1118        LangfuseActivityPropagation.SetTraceMetadata(activity, "homeTeams", PredictionTelemetryMetadata.BuildDelimitedFi
 1119        LangfuseActivityPropagation.SetTraceMetadata(activity, "awayTeams", PredictionTelemetryMetadata.BuildDelimitedFi
 1120        LangfuseActivityPropagation.SetTraceMetadata(activity, "teams", PredictionTelemetryMetadata.BuildDelimitedFilter
 1121        LangfuseActivityPropagation.SetTraceMetadata(activity, "selectedMatch", $"{match.HomeTeam} vs {match.AwayTeam}",
 122
 123        // Set trace input
 1124        var traceInput = new
 1125        {
 1126            community = settings.Community,
 1127            matchday,
 1128            model = settings.Model,
 1129            match = $"{match.HomeTeam} vs {match.AwayTeam}"
 1130        };
 1131        activity?.SetTag("langfuse.trace.input", JsonSerializer.Serialize(traceInput));
 132
 1133        _console.MarkupLine($"[dim]Matchday: {matchday}[/]");
 134
 1135        if (match.IsCancelled)
 136        {
 1137            _console.MarkupLine($"[yellow]  ⚠ {match.HomeTeam} vs {match.AwayTeam} is cancelled (Abgesagt). " +
 1138                $"Processing with inherited time - prediction may need re-evaluation when rescheduled.[/]");
 139        }
 140
 141        // Step 3: Generate prediction
 1142        _console.MarkupLine($"[yellow]  → Generating prediction...[/]");
 143
 144        // Get context using hybrid approach (database first, fallback to on-demand)
 1145        var contextDocuments = await GetHybridContextAsync(
 1146            contextRepository,
 1147            contextProvider,
 1148            match.HomeTeam,
 1149            match.AwayTeam,
 1150            settings.Community);
 151
 1152        _console.MarkupLine($"[dim]    Using {contextDocuments.Count} context documents[/]");
 153
 154        // Predict the match
 1155        var telemetryMetadata = new PredictionTelemetryMetadata(
 1156            HomeTeam: match.HomeTeam,
 1157            AwayTeam: match.AwayTeam,
 1158            RepredictionIndex: 0);
 159
 1160        var prediction = await predictionService.PredictMatchAsync(match, contextDocuments, settings.WithJustification, 
 161
 1162        if (prediction != null)
 163        {
 1164            _console.MarkupLine($"[green]  ✓ Prediction:[/] {prediction.HomeGoals}:{prediction.AwayGoals}");
 1165            WriteJustificationIfNeeded(prediction, settings.WithJustification);
 166
 167            // Set trace output
 1168            var traceOutput = new
 1169            {
 1170                match = $"{match.HomeTeam} vs {match.AwayTeam}",
 1171                prediction = $"{prediction.HomeGoals}:{prediction.AwayGoals}"
 1172            };
 1173            activity?.SetTag("langfuse.trace.output", JsonSerializer.Serialize(traceOutput));
 174        }
 175        else
 176        {
 1177            _console.MarkupLine($"[red]  ✗ Failed to generate prediction[/]");
 1178            activity?.SetTag("langfuse.trace.output", JsonSerializer.Serialize(new { error = "Failed to generate predict
 179        }
 180
 181        // Display token usage summary
 1182        var summary = tokenUsageTracker.GetCompactSummary();
 1183        _console.MarkupLine($"[dim]Token usage (uncached/cached/reasoning/output/$cost): {summary}[/]");
 1184    }
 185
 186    /// <summary>
 187    /// Retrieves all available context documents from the database for the given community context.
 188    /// </summary>
 189    private async Task<Dictionary<string, DocumentContext>> GetMatchContextDocumentsAsync(
 190        IContextRepository contextRepository,
 191        string homeTeam,
 192        string awayTeam,
 193        string communityContext)
 194    {
 1195        var contextDocuments = new Dictionary<string, DocumentContext>();
 1196        var homeAbbreviation = GetTeamAbbreviation(homeTeam);
 1197        var awayAbbreviation = GetTeamAbbreviation(awayTeam);
 198
 1199        var requiredDocuments = new[]
 1200        {
 1201            "bundesliga-standings.csv",
 1202            $"community-rules-{communityContext}.md",
 1203            $"recent-history-{homeAbbreviation}.csv",
 1204            $"recent-history-{awayAbbreviation}.csv",
 1205            $"home-history-{homeAbbreviation}.csv",
 1206            $"away-history-{awayAbbreviation}.csv",
 1207            $"head-to-head-{homeAbbreviation}-vs-{awayAbbreviation}.csv"
 1208        };
 209
 1210        var optionalDocuments = new[]
 1211        {
 1212            $"{homeAbbreviation}-transfers.csv",
 1213            $"{awayAbbreviation}-transfers.csv"
 1214        };
 215
 1216        _console.MarkupLine($"[dim]    Looking for {requiredDocuments.Length} specific context documents in database[/]"
 217
 218        try
 219        {
 1220            foreach (var documentName in requiredDocuments)
 221            {
 1222                var contextDoc = await contextRepository.GetLatestContextDocumentAsync(documentName, communityContext);
 1223                if (contextDoc != null)
 224                {
 1225                    contextDocuments[documentName] = new DocumentContext(contextDoc.DocumentName, contextDoc.Content);
 1226                    _console.MarkupLine($"[dim]      ✓ Retrieved {documentName} (version {contextDoc.Version})[/]");
 227                }
 228                else
 229                {
 1230                    _console.MarkupLine($"[dim]      ✗ Missing {documentName}[/]");
 231                }
 1232            }
 233
 1234            foreach (var documentName in optionalDocuments)
 235            {
 236                try
 237                {
 1238                    var contextDoc = await contextRepository.GetLatestContextDocumentAsync(documentName, communityContex
 1239                    if (contextDoc != null)
 240                    {
 1241                        contextDocuments[documentName] = new DocumentContext(contextDoc.DocumentName, contextDoc.Content
 1242                        _console.MarkupLine($"[dim]      ✓ Retrieved optional {documentName} (version {contextDoc.Versio
 243                    }
 244                    else
 245                    {
 1246                        _console.MarkupLine($"[dim]      · Missing optional {documentName}[/]");
 247                    }
 1248                }
 1249                catch (Exception optEx)
 250                {
 1251                    _console.MarkupLine($"[dim]      · Failed optional {documentName}: {optEx.Message}[/]");
 1252                }
 1253            }
 1254        }
 1255        catch (Exception ex)
 256        {
 1257            _console.MarkupLine($"[red]    Warning: Failed to retrieve context from database: {ex.Message}[/]");
 1258        }
 259
 1260        return contextDocuments;
 1261    }
 262
 263    /// <summary>
 264    /// Gets context documents using database first, falling back to on-demand context provider if needed.
 265    /// </summary>
 266    private async Task<List<DocumentContext>> GetHybridContextAsync(
 267        IContextRepository contextRepository,
 268        IKicktippContextProvider contextProvider,
 269        string homeTeam,
 270        string awayTeam,
 271        string communityContext)
 272    {
 1273        var contextDocuments = new List<DocumentContext>();
 1274        var databaseContexts = await GetMatchContextDocumentsAsync(
 1275            contextRepository,
 1276            homeTeam,
 1277            awayTeam,
 1278            communityContext);
 279
 1280        var homeAbbreviation = GetTeamAbbreviation(homeTeam);
 1281        var awayAbbreviation = GetTeamAbbreviation(awayTeam);
 1282        var requiredDocuments = new[]
 1283        {
 1284            "bundesliga-standings.csv",
 1285            $"community-rules-{communityContext}.md",
 1286            $"recent-history-{homeAbbreviation}.csv",
 1287            $"recent-history-{awayAbbreviation}.csv",
 1288            $"home-history-{homeAbbreviation}.csv",
 1289            $"away-history-{awayAbbreviation}.csv",
 1290            $"head-to-head-{homeAbbreviation}-vs-{awayAbbreviation}.csv"
 1291        };
 292
 1293        int requiredPresent = requiredDocuments.Count(d => databaseContexts.ContainsKey(d));
 1294        int requiredTotal = requiredDocuments.Length;
 295
 1296        if (requiredPresent == requiredTotal)
 297        {
 1298            _console.MarkupLine($"[green]    Using {databaseContexts.Count} context documents from database (all require
 1299            contextDocuments.AddRange(databaseContexts.Values);
 300        }
 301        else
 302        {
 1303            _console.MarkupLine($"[yellow]    Warning: Only found {requiredPresent}/{requiredTotal} required context doc
 304
 1305            contextDocuments.AddRange(databaseContexts.Values);
 306
 1307            var existingNames = new HashSet<string>(contextDocuments.Select(c => c.Name), StringComparer.OrdinalIgnoreCa
 1308            await foreach (var context in contextProvider.GetMatchContextAsync(homeTeam, awayTeam))
 309            {
 1310                if (existingNames.Add(context.Name))
 311                {
 1312                    contextDocuments.Add(context);
 313                }
 314            }
 315
 1316            _console.MarkupLine($"[yellow]    Using {contextDocuments.Count} merged context documents (database + on-dem
 1317        }
 318
 1319        return contextDocuments;
 1320    }
 321
 322    /// <summary>
 323    /// Gets a team abbreviation for file naming.
 324    /// </summary>
 325    private static string GetTeamAbbreviation(string teamName)
 326    {
 1327        var abbreviations = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
 1328        {
 1329            { "1. FC Heidenheim 1846", "fch" },
 1330            { "1. FC Köln", "fck" },
 1331            { "1. FC Union Berlin", "fcu" },
 1332            { "1899 Hoffenheim", "tsg" },
 1333            { "Bayer 04 Leverkusen", "b04" },
 1334            { "Bor. Mönchengladbach", "bmg" },
 1335            { "Borussia Dortmund", "bvb" },
 1336            { "Eintracht Frankfurt", "sge" },
 1337            { "FC Augsburg", "fca" },
 1338            { "FC Bayern München", "fcb" },
 1339            { "FC St. Pauli", "fcs" },
 1340            { "FSV Mainz 05", "m05" },
 1341            { "Hamburger SV", "hsv" },
 1342            { "RB Leipzig", "rbl" },
 1343            { "SC Freiburg", "scf" },
 1344            { "VfB Stuttgart", "vfb" },
 1345            { "VfL Wolfsburg", "wob" },
 1346            { "Werder Bremen", "svw" }
 1347        };
 348
 1349        if (abbreviations.TryGetValue(teamName, out var abbreviation))
 350        {
 1351            return abbreviation;
 352        }
 353
 1354        var words = teamName.Split(' ', StringSplitOptions.RemoveEmptyEntries);
 1355        var abbr = new System.Text.StringBuilder();
 356
 1357        foreach (var word in words.Take(3))
 358        {
 1359            if (word.Length > 0 && char.IsLetter(word[0]))
 360            {
 1361                abbr.Append(char.ToLowerInvariant(word[0]));
 362            }
 363        }
 364
 1365        return abbr.Length > 0 ? abbr.ToString() : "unknown";
 366    }
 367
 368    private void WriteJustificationIfNeeded(Prediction? prediction, bool includeJustification, bool fromDatabase = false
 369    {
 1370        if (!includeJustification || prediction == null)
 371        {
 1372            return;
 373        }
 374
 1375        var sourceLabel = fromDatabase ? "stored prediction" : "model response";
 376
 1377        var justificationWriter = new JustificationConsoleWriter(_console);
 1378        justificationWriter.WriteJustification(
 1379            prediction.Justification,
 1380            "[dim]    ↳ Justification:[/]",
 1381            "        ",
 1382            $"[yellow]    ↳ No justification available for this {sourceLabel}[/]");
 1383    }
 384}