< Summary

Information
Class: Orchestrator.Commands.Observability.ExportExperimentDataset.ExportExperimentDatasetCommand
Assembly: Orchestrator
File(s): /home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Observability/ExportExperimentDataset/ExportExperimentDatasetCommand.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 97
Coverable lines: 97
Total lines: 198
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 34
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%210%
.ctor(...)100%210%
ExecuteAsync()0%210140%
BuildItem(...)0%620%
RehydrateForPromptOutput(...)100%210%
ParseMatchdays(...)0%4260%
BuildDatasetName(...)100%210%
ResolveOutputPath(...)0%620%
BuildItemId(...)100%210%
Slugify(...)0%110100%

File(s)

/home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Observability/ExportExperimentDataset/ExportExperimentDatasetCommand.cs

#LineLine coverage
 1using System.Globalization;
 2using System.Text;
 3using System.Text.Json;
 4using EHonda.KicktippAi.Core;
 5using Microsoft.Extensions.Logging;
 6using NodaTime;
 7using OpenAiIntegration;
 8using Orchestrator.Infrastructure.Factories;
 9using Spectre.Console;
 10using Spectre.Console.Cli;
 11
 12namespace Orchestrator.Commands.Observability.ExportExperimentDataset;
 13
 14public sealed class ExportExperimentDatasetCommand : AsyncCommand<ExportExperimentDatasetSettings>
 15{
 16    private const string Season = "2025/2026";
 017    private static readonly DateTimeZone BundesligaTimeZone = DateTimeZoneProviders.Tzdb["Europe/Berlin"];
 018    private static readonly JsonSerializerOptions OutputJsonOptions = new()
 019    {
 020        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
 021        WriteIndented = true
 022    };
 23
 24    private readonly IAnsiConsole _console;
 25    private readonly IFirebaseServiceFactory _firebaseServiceFactory;
 26    private readonly ILogger<ExportExperimentDatasetCommand> _logger;
 27
 028    public ExportExperimentDatasetCommand(
 029        IAnsiConsole console,
 030        IFirebaseServiceFactory firebaseServiceFactory,
 031        ILogger<ExportExperimentDatasetCommand> logger)
 32    {
 033        _console = console;
 034        _firebaseServiceFactory = firebaseServiceFactory;
 035        _logger = logger;
 036    }
 37
 38    public override async Task<int> ExecuteAsync(CommandContext context, ExportExperimentDatasetSettings settings)
 39    {
 40        try
 41        {
 042            var matchOutcomeRepository = _firebaseServiceFactory.CreateMatchOutcomeRepository();
 043            var matchdays = ParseMatchdays(settings.Matchdays);
 044            var datasetName = BuildDatasetName(settings.CommunityContext);
 45
 046            _console.MarkupLine($"[green]Exporting hosted experiment dataset:[/] [yellow]{Markup.Escape(datasetName)}[/]
 47
 048            var items = new List<HostedMatchExperimentDatasetItem>();
 49
 050            foreach (var matchday in matchdays)
 51            {
 052                var outcomes = await matchOutcomeRepository.GetMatchdayOutcomesAsync(matchday, settings.CommunityContext
 53
 054                foreach (var outcome in outcomes)
 55                {
 056                    if (!outcome.HasOutcome || outcome.HomeGoals is null || outcome.AwayGoals is null)
 57                    {
 58                        continue;
 59                    }
 60
 061                    items.Add(BuildItem(outcome));
 62                }
 63            }
 64
 065            items.Sort((left, right) => string.Compare(left.Id, right.Id, StringComparison.Ordinal));
 66
 067            var export = new ExportedExperimentDataset(datasetName, items.AsReadOnly());
 068            var outputPath = ResolveOutputPath(settings);
 069            Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
 70
 071            await File.WriteAllTextAsync(
 072                outputPath,
 073                JsonSerializer.Serialize(export, OutputJsonOptions));
 74
 075            _console.MarkupLine($"[green]Wrote dataset artifact:[/] [yellow]{Markup.Escape(outputPath)}[/]");
 076            _console.MarkupLine($"[blue]Exported items:[/] {items.Count}");
 77
 078            if (items.Count > 0)
 79            {
 080                _console.MarkupLine($"[blue]First item id:[/] {Markup.Escape(items[0].Id)}");
 081                _console.MarkupLine($"[blue]Last item id:[/] {Markup.Escape(items[^1].Id)}");
 82            }
 83
 084            return 0;
 85        }
 086        catch (Exception ex)
 87        {
 088            _logger.LogError(ex, "Error exporting hosted experiment dataset");
 089            _console.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
 090            return 1;
 91        }
 092    }
 93
 94    private static HostedMatchExperimentDatasetItem BuildItem(PersistedMatchOutcome outcome)
 95    {
 096        var tippSpielId = outcome.TippSpielId ?? throw new InvalidOperationException(
 097            $"Persisted outcome for {outcome.HomeTeam} vs {outcome.AwayTeam} is missing tippspielId.");
 98
 099        var promptMatch = RehydrateForPromptOutput(outcome);
 0100        using var matchJsonDocument = JsonDocument.Parse(PredictionPromptComposer.CreateMatchJson(promptMatch));
 101
 0102        return new HostedMatchExperimentDatasetItem(
 0103            BuildItemId(outcome.Competition, outcome.CommunityContext, tippSpielId),
 0104            matchJsonDocument.RootElement.Clone(),
 0105            new HostedMatchExperimentExpectedOutput(
 0106                outcome.HomeGoals!.Value,
 0107                outcome.AwayGoals!.Value),
 0108            new HostedMatchExperimentMetadata(
 0109                outcome.Competition,
 0110                Season,
 0111                outcome.CommunityContext,
 0112                outcome.Matchday,
 0113                $"md{outcome.Matchday:00}",
 0114                outcome.HomeTeam,
 0115                outcome.AwayTeam,
 0116                tippSpielId));
 0117    }
 118
 119    private static Match RehydrateForPromptOutput(PersistedMatchOutcome outcome)
 120    {
 0121        var instant = outcome.StartsAt.ToInstant();
 0122        var offset = BundesligaTimeZone.GetUtcOffset(instant);
 0123        var localizedStartsAt = instant.InZone(DateTimeZone.ForOffset(offset));
 0124        return new Match(outcome.HomeTeam, outcome.AwayTeam, localizedStartsAt, outcome.Matchday);
 125    }
 126
 127    private static IReadOnlyList<int> ParseMatchdays(string? matchdays)
 128    {
 0129        if (string.IsNullOrWhiteSpace(matchdays))
 130        {
 0131            return Enumerable.Range(1, 34).ToList().AsReadOnly();
 132        }
 133
 0134        return matchdays
 0135            .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 0136            .Select(segment => int.Parse(segment, CultureInfo.InvariantCulture))
 0137            .Distinct()
 0138            .OrderBy(matchday => matchday)
 0139            .ToList()
 0140            .AsReadOnly();
 141    }
 142
 143    private static string BuildDatasetName(string communityContext)
 144    {
 0145        return $"match-predictions/bundesliga-2025-26/{communityContext}";
 146    }
 147
 148    private static string ResolveOutputPath(ExportExperimentDatasetSettings settings)
 149    {
 0150        if (!string.IsNullOrWhiteSpace(settings.OutputPath))
 151        {
 0152            return Path.GetFullPath(settings.OutputPath);
 153        }
 154
 0155        return Path.GetFullPath(Path.Combine(
 0156            "artifacts",
 0157            "langfuse-dataset",
 0158            $"{Slugify(settings.CommunityContext)}.json"));
 159    }
 160
 161    private static string BuildItemId(string competition, string communityContext, string tippSpielId)
 162    {
 0163        return string.Join(
 0164            "__",
 0165            Slugify(competition),
 0166            Slugify(communityContext),
 0167            $"ts{Slugify(tippSpielId)}");
 168    }
 169
 170    private static string Slugify(string value)
 171    {
 0172        var normalized = value.Normalize(NormalizationForm.FormD);
 0173        var builder = new StringBuilder(normalized.Length);
 174
 0175        foreach (var character in normalized)
 176        {
 0177            if (CharUnicodeInfo.GetUnicodeCategory(character) == UnicodeCategory.NonSpacingMark)
 178            {
 179                continue;
 180            }
 181
 0182            if (char.IsLetterOrDigit(character))
 183            {
 0184                builder.Append(char.ToLowerInvariant(character));
 0185                continue;
 186            }
 187
 0188            if (builder.Length == 0 || builder[^1] == '-')
 189            {
 190                continue;
 191            }
 192
 0193            builder.Append('-');
 194        }
 195
 0196        return builder.ToString().Trim('-');
 197    }
 198}