< Summary

Information
Class: Orchestrator.Commands.Observability.PrepareSlice.PrepareSliceCommand
Assembly: Orchestrator
File(s): /home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Observability/PrepareSlice/PrepareSliceCommand.cs
Line coverage
90%
Covered lines: 150
Uncovered lines: 16
Coverable lines: 166
Total lines: 284
Line coverage: 90.3%
Branch coverage
78%
Covered branches: 44
Total branches: 56
Branch coverage: 78.5%
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()66.67%121293.24%
LoadSourceItemsAsync()100%1818100%
GetStartsAt(...)50%8883.33%
SelectRandomItems(...)75%4477.78%
ParseMatchdays(...)83.33%6685.71%
BuildDefaultSourcePoolKey(...)75%44100%
BuildSliceDatasetMetadata(...)100%22100%
ResolveOutputDirectory(...)50%4222.22%
WriteJsonFileAsync<T>(...)100%11100%

File(s)

/home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Observability/PrepareSlice/PrepareSliceCommand.cs

#LineLine coverage
 1using System.Globalization;
 2using System.Text.Json;
 3using EHonda.KicktippAi.Core;
 4using Microsoft.Extensions.Logging;
 5using NodaTime;
 6using Orchestrator.Commands.Observability.Experiments;
 7using Orchestrator.Commands.Observability.ExportExperimentDataset;
 8using Orchestrator.Infrastructure.Factories;
 9using Spectre.Console;
 10using Spectre.Console.Cli;
 11
 12namespace Orchestrator.Commands.Observability.PrepareSlice;
 13
 14public sealed class PrepareSliceCommand : AsyncCommand<PrepareSliceSettings>
 15{
 16    private readonly IAnsiConsole _console;
 17    private readonly IFirebaseServiceFactory _firebaseServiceFactory;
 18    private readonly ILogger<PrepareSliceCommand> _logger;
 19
 120    public PrepareSliceCommand(
 121        IAnsiConsole console,
 122        IFirebaseServiceFactory firebaseServiceFactory,
 123        ILogger<PrepareSliceCommand> logger)
 24    {
 125        _console = console;
 126        _firebaseServiceFactory = firebaseServiceFactory;
 127        _logger = logger;
 128    }
 29
 30    protected override async Task<int> ExecuteAsync(CommandContext context, PrepareSliceSettings settings, CancellationT
 31    {
 32        try
 33        {
 134            var matchOutcomeRepository = _firebaseServiceFactory.CreateMatchOutcomeRepository();
 135            var matchdays = ParseMatchdays(settings.Matchdays);
 136            var startsAfter = EvaluationTimeParser.ParseOrNull(settings.StartsAfter);
 137            var normalizedStartsAfter = EvaluationTimeParser.NormalizeOrNull(settings.StartsAfter);
 138            var availableItems = await LoadSourceItemsAsync(
 139                matchOutcomeRepository,
 140                settings.CommunityContext,
 141                matchdays,
 142                startsAfter,
 143                cancellationToken);
 44
 145            if (availableItems.Count == 0)
 46            {
 047                throw new InvalidOperationException("No completed historical matches were found for the requested slice 
 48            }
 49
 150            var sampleSeed = settings.SampleSeed ?? int.Parse(
 151                DateTimeOffset.UtcNow.ToString("yyyyMMdd", CultureInfo.InvariantCulture),
 152                CultureInfo.InvariantCulture);
 153            var sliceKey = string.IsNullOrWhiteSpace(settings.SliceKey)
 154                ? $"random-{settings.SampleSize}-seed-{sampleSeed}"
 155                : settings.SliceKey.Trim();
 156            var sourcePoolKey = string.IsNullOrWhiteSpace(settings.SourcePoolKey)
 157                ? BuildDefaultSourcePoolKey(matchdays, startsAfter)
 158                : settings.SourcePoolKey.Trim();
 159            var selectedItems = SelectRandomItems(availableItems, settings.SampleSize, sampleSeed)
 160                .OrderBy(item => item.SourceDatasetItemId, StringComparer.Ordinal)
 161                .Select(item => item with
 162                {
 163                    SliceDatasetItemId = ExperimentArtifactSupport.BuildSliceDatasetItemId(item.SourceDatasetItemId, sli
 164                })
 165                .ToList();
 66
 167            var sourceDatasetName = ExperimentArtifactSupport.BuildSourceDatasetName(settings.CommunityContext);
 168            var sliceDatasetName = settings.DatasetName
 169                ?? $"{sourceDatasetName}/slices/{sourcePoolKey}/{sliceKey}";
 170            var outputDirectory = ResolveOutputDirectory(settings.OutputDirectory, settings.CommunityContext, sourcePool
 171            var sliceArtifactPath = Path.Combine(outputDirectory, "slice-dataset.json");
 172            var sliceManifestPath = Path.Combine(outputDirectory, "slice-manifest.json");
 73
 174            Directory.CreateDirectory(outputDirectory);
 75
 176            var bundle = PreparedExperimentBundleBuilder.Build(
 177                selectedItems,
 178                settings.CommunityContext,
 179                sourceDatasetName,
 180                sliceDatasetName,
 181                sliceKey,
 182                settings.SliceKind.Trim(),
 183                settings.SampleMethod.Trim(),
 184                sourcePoolKey,
 185                sampleSeed,
 186                extraDatasetMetadata: BuildSliceDatasetMetadata(normalizedStartsAfter));
 187            var manifest = bundle.Manifest with
 188            {
 189                StartsAfter = normalizedStartsAfter
 190            };
 91
 192            await WriteJsonFileAsync(sliceArtifactPath, bundle.Artifact, cancellationToken);
 193            await WriteJsonFileAsync(sliceManifestPath, manifest, cancellationToken);
 94
 195            var summary = new
 196            {
 197                mode = "slice",
 198                sourceDatasetName,
 199                datasetName = manifest.SliceDatasetName,
 1100                manifest.CommunityContext,
 1101                manifest.SourcePoolKey,
 1102                manifest.SliceKey,
 1103                manifest.SliceKind,
 1104                manifest.SampleMethod,
 1105                manifest.SampleSize,
 1106                manifest.SampleSeed,
 1107                matchdays,
 1108                manifest.StartsAfter,
 1109                manifest.SelectedItemIds,
 1110                manifest.SelectedItemIdsHash,
 1111                outputDirectory,
 1112                sliceArtifactPath,
 1113                sliceManifestPath
 1114            };
 115
 1116            _console.WriteLine(JsonSerializer.Serialize(summary, PreparedExperimentCommandSupport.JsonOptions));
 1117            return 0;
 118        }
 0119        catch (Exception ex)
 120        {
 0121            _logger.LogError(ex, "Error preparing slice experiment artifact");
 0122            _console.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
 0123            return 1;
 124        }
 1125    }
 126
 127    private static async Task<IReadOnlyList<PreparedExperimentSourceItem>> LoadSourceItemsAsync(
 128        IMatchOutcomeRepository matchOutcomeRepository,
 129        string communityContext,
 130        IReadOnlyList<int> matchdays,
 131        DateTimeOffset? startsAfter,
 132        CancellationToken cancellationToken)
 133    {
 1134        var sourceItems = new List<PreparedExperimentSourceItem>();
 1135        var startsAfterInstant = startsAfter is null
 1136            ? (Instant?)null
 1137            : Instant.FromDateTimeOffset(startsAfter.Value);
 138
 1139        foreach (var matchday in matchdays)
 140        {
 1141            var outcomes = await matchOutcomeRepository.GetMatchdayOutcomesAsync(matchday, communityContext, cancellatio
 1142            foreach (var outcome in outcomes)
 143            {
 1144                if (!outcome.HasOutcome || outcome.HomeGoals is null || outcome.AwayGoals is null)
 145                {
 146                    continue;
 147                }
 148
 1149                if (startsAfterInstant is not null && outcome.StartsAt.ToInstant() <= startsAfterInstant.Value)
 150                {
 151                    continue;
 152                }
 153
 1154                var datasetItem = ExperimentArtifactSupport.BuildHostedDatasetItem(outcome);
 1155                sourceItems.Add(new PreparedExperimentSourceItem(
 1156                    datasetItem.Id,
 1157                    datasetItem.Id,
 1158                    datasetItem.Id,
 1159                    datasetItem.Metadata.Competition,
 1160                    datasetItem.Metadata.Season,
 1161                    datasetItem.Metadata.CommunityContext,
 1162                    datasetItem.Metadata.Matchday,
 1163                    datasetItem.Metadata.MatchdayLabel,
 1164                    datasetItem.Metadata.HomeTeam,
 1165                    datasetItem.Metadata.AwayTeam,
 1166                    GetStartsAt(datasetItem),
 1167                    datasetItem.Metadata.TippSpielId,
 1168                    datasetItem.ExpectedOutput.HomeGoals,
 1169                    datasetItem.ExpectedOutput.AwayGoals));
 170            }
 171        }
 172
 1173        return sourceItems
 1174            .OrderBy(item => item.SourceDatasetItemId, StringComparer.Ordinal)
 1175            .ToList();
 1176    }
 177
 178    private static string GetStartsAt(HostedMatchExperimentDatasetItem item)
 179    {
 1180        if (item.Input.ValueKind != JsonValueKind.Object
 1181            || !item.Input.TryGetProperty("startsAt", out var startsAt)
 1182            || startsAt.ValueKind != JsonValueKind.String
 1183            || string.IsNullOrWhiteSpace(startsAt.GetString()))
 184        {
 0185            throw new InvalidOperationException($"Dataset item '{item.Id}' is missing input.startsAt.");
 186        }
 187
 1188        return startsAt.GetString()!;
 189    }
 190
 191    private static IReadOnlyList<PreparedExperimentSourceItem> SelectRandomItems(
 192        IReadOnlyList<PreparedExperimentSourceItem> items,
 193        int count,
 194        int seed)
 195    {
 1196        if (items.Count < count)
 197        {
 0198            throw new InvalidOperationException(
 0199                $"Requested sample size {count} exceeds available dataset item count {items.Count}.");
 200        }
 201
 1202        var buffer = items.ToList();
 1203        var random = new Random(seed);
 1204        for (var index = buffer.Count - 1; index > 0; index -= 1)
 205        {
 1206            var swapIndex = random.Next(index + 1);
 1207            (buffer[index], buffer[swapIndex]) = (buffer[swapIndex], buffer[index]);
 208        }
 209
 1210        return buffer.Take(count).ToList();
 211    }
 212
 213    private static IReadOnlyList<int> ParseMatchdays(string? matchdays)
 214    {
 1215        if (string.IsNullOrWhiteSpace(matchdays))
 216        {
 0217            return Enumerable.Range(1, 34).ToList().AsReadOnly();
 218        }
 219
 1220        return matchdays
 1221            .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 1222            .Select(segment => int.Parse(segment, CultureInfo.InvariantCulture))
 1223            .Distinct()
 1224            .OrderBy(matchday => matchday)
 1225            .ToList()
 1226            .AsReadOnly();
 227    }
 228
 229    private static string BuildDefaultSourcePoolKey(IReadOnlyList<int> matchdays, DateTimeOffset? startsAfter)
 230    {
 1231        var baseKey = matchdays.SequenceEqual(Enumerable.Range(1, 34))
 1232            ? "all-matchdays"
 1233            : $"matchdays-{string.Join('-', matchdays)}";
 234
 1235        if (startsAfter is null)
 236        {
 1237            return baseKey;
 238        }
 239
 1240        var utcToken = startsAfter.Value
 1241            .ToUniversalTime()
 1242            .ToString("yyyyMMdd't'HHmmss'z'", CultureInfo.InvariantCulture)
 1243            .ToLowerInvariant();
 1244        return $"{baseKey}-after-{utcToken}";
 245    }
 246
 247    private static IReadOnlyDictionary<string, object?>? BuildSliceDatasetMetadata(string? startsAfter)
 248    {
 1249        if (string.IsNullOrWhiteSpace(startsAfter))
 250        {
 1251            return null;
 252        }
 253
 1254        return new Dictionary<string, object?>
 1255        {
 1256            ["startsAfter"] = startsAfter
 1257        };
 258    }
 259
 260    private static string ResolveOutputDirectory(
 261        string? outputDirectoryOverride,
 262        string communityContext,
 263        string sourcePoolKey,
 264        string sliceKey)
 265    {
 1266        if (!string.IsNullOrWhiteSpace(outputDirectoryOverride))
 267        {
 1268            return Path.GetFullPath(outputDirectoryOverride);
 269        }
 270
 0271        return Path.GetFullPath(Path.Combine(
 0272            "artifacts",
 0273            "langfuse-experiments",
 0274            "slices",
 0275            ExperimentArtifactSupport.Slugify(communityContext),
 0276            sourcePoolKey,
 0277            sliceKey));
 278    }
 279
 280    private static Task WriteJsonFileAsync<T>(string path, T value, CancellationToken cancellationToken)
 281    {
 1282        return File.WriteAllTextAsync(path, JsonSerializer.Serialize(value, PreparedExperimentCommandSupport.JsonOptions
 283    }
 284}