< Summary

Information
Class: Orchestrator.Commands.Observability.ExperimentArtifactSupport
Assembly: Orchestrator
File(s): /home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Observability/ExperimentArtifactSupport.cs
Line coverage
75%
Covered lines: 97
Uncovered lines: 31
Coverable lines: 128
Total lines: 289
Line coverage: 75.7%
Branch coverage
46%
Covered branches: 26
Total branches: 56
Branch coverage: 46.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Observability/ExperimentArtifactSupport.cs

#LineLine coverage
 1using System.Globalization;
 2using System.Security.Cryptography;
 3using System.Text;
 4using System.Text.Json;
 5using EHonda.KicktippAi.Core;
 6using NodaTime;
 7using OpenAiIntegration;
 8using Orchestrator.Commands.Observability.ExportExperimentDataset;
 9using Match = EHonda.KicktippAi.Core.Match;
 10
 11namespace Orchestrator.Commands.Observability;
 12
 13internal static class ExperimentArtifactSupport
 14{
 15    public const string Season = "2025/2026";
 16
 117    private static readonly DateTimeZone BundesligaTimeZone = DateTimeZoneProviders.Tzdb["Europe/Berlin"];
 18
 19    public static HostedMatchExperimentDatasetItem BuildHostedDatasetItem(PersistedMatchOutcome outcome)
 20    {
 121        ArgumentNullException.ThrowIfNull(outcome);
 22
 123        var tippSpielId = outcome.TippSpielId ?? throw new InvalidOperationException(
 124            $"Persisted outcome for {outcome.HomeTeam} vs {outcome.AwayTeam} is missing tippspielId.");
 25
 126        if (!outcome.HasOutcome || outcome.HomeGoals is null || outcome.AwayGoals is null)
 27        {
 028            throw new InvalidOperationException(
 029                $"Persisted outcome for {outcome.HomeTeam} vs {outcome.AwayTeam} does not contain a completed score.");
 30        }
 31
 132        var promptMatch = RehydrateForPromptOutput(outcome);
 133        using var matchJsonDocument = JsonDocument.Parse(PredictionPromptComposer.CreateMatchJson(promptMatch));
 34
 135        return new HostedMatchExperimentDatasetItem(
 136            BuildHostedDatasetItemId(outcome.Competition, outcome.CommunityContext, tippSpielId),
 137            matchJsonDocument.RootElement.Clone(),
 138            new HostedMatchExperimentExpectedOutput(
 139                outcome.HomeGoals.Value,
 140                outcome.AwayGoals.Value),
 141            new HostedMatchExperimentMetadata(
 142                outcome.Competition,
 143                Season,
 144                outcome.CommunityContext,
 145                outcome.Matchday,
 146                $"md{outcome.Matchday:00}",
 147                outcome.HomeTeam,
 148                outcome.AwayTeam,
 149                tippSpielId));
 150    }
 51
 52    public static HostedMatchExperimentDatasetItem BuildHostedDatasetItem(
 53        CollectedMatchOutcome outcome,
 54        string communityContext,
 55        string competition)
 56    {
 157        ArgumentNullException.ThrowIfNull(outcome);
 158        ArgumentException.ThrowIfNullOrWhiteSpace(communityContext);
 159        ArgumentException.ThrowIfNullOrWhiteSpace(competition);
 60
 161        var tippSpielId = outcome.TippSpielId ?? throw new InvalidOperationException(
 162            $"Collected outcome for {outcome.HomeTeam} vs {outcome.AwayTeam} is missing tippspielId.");
 63
 164        if (!outcome.HasOutcome || outcome.HomeGoals is null || outcome.AwayGoals is null)
 65        {
 066            throw new InvalidOperationException(
 067                $"Collected outcome for {outcome.HomeTeam} vs {outcome.AwayTeam} does not contain a completed score.");
 68        }
 69
 170        var promptMatch = RehydrateForPromptOutput(new Match(outcome.HomeTeam, outcome.AwayTeam, outcome.StartsAt, outco
 171        using var matchJsonDocument = JsonDocument.Parse(PredictionPromptComposer.CreateMatchJson(promptMatch));
 72
 173        return new HostedMatchExperimentDatasetItem(
 174            BuildHostedDatasetItemId(competition, communityContext, tippSpielId),
 175            matchJsonDocument.RootElement.Clone(),
 176            new HostedMatchExperimentExpectedOutput(
 177                outcome.HomeGoals.Value,
 178                outcome.AwayGoals.Value),
 179            new HostedMatchExperimentMetadata(
 180                competition,
 181                Season,
 182                communityContext,
 183                outcome.Matchday,
 184                $"md{outcome.Matchday:00}",
 185                outcome.HomeTeam,
 186                outcome.AwayTeam,
 187                tippSpielId));
 188    }
 89
 90    public static Match RehydrateForPromptOutput(PersistedMatchOutcome outcome)
 91    {
 192        ArgumentNullException.ThrowIfNull(outcome);
 193        return RehydrateForPromptOutput(new Match(outcome.HomeTeam, outcome.AwayTeam, outcome.StartsAt, outcome.Matchday
 94    }
 95
 96    public static Match RehydrateForPromptOutput(Match match)
 97    {
 198        ArgumentNullException.ThrowIfNull(match);
 99
 1100        var instant = match.StartsAt.ToInstant();
 1101        var offset = BundesligaTimeZone.GetUtcOffset(instant);
 1102        var localizedStartsAt = instant.InZone(DateTimeZone.ForOffset(offset));
 1103        return new Match(match.HomeTeam, match.AwayTeam, localizedStartsAt, match.Matchday, match.IsCancelled);
 104    }
 105
 106    public static string BuildSourceDatasetName(string communityContext)
 107    {
 1108        ArgumentException.ThrowIfNullOrWhiteSpace(communityContext);
 1109        return $"match-predictions/bundesliga-2025-26/{communityContext}";
 110    }
 111
 112    public static string BuildCanonicalDatasetName(string communityContext)
 113    {
 0114        return BuildSourceDatasetName(communityContext);
 115    }
 116    public static string BuildHostedDatasetItemId(string competition, string communityContext, string tippSpielId)
 117    {
 1118        return string.Join(
 1119            "__",
 1120            Slugify(competition),
 1121            Slugify(communityContext),
 1122            $"ts{Slugify(tippSpielId)}");
 123    }
 124
 125    public static string BuildSliceDatasetItemId(string sourceItemId, string sliceKey)
 126    {
 1127        return $"{sourceItemId}__slice__{sliceKey}";
 128    }
 129
 130    public static string BuildRepeatedSliceDatasetItemId(
 131        string sourceItemId,
 132        string sliceKey,
 133        int repetitionIndex,
 134        int totalRepetitions)
 135    {
 1136        ArgumentException.ThrowIfNullOrWhiteSpace(sourceItemId);
 1137        ArgumentException.ThrowIfNullOrWhiteSpace(sliceKey);
 138
 1139        if (repetitionIndex < 1)
 140        {
 0141            throw new ArgumentOutOfRangeException(nameof(repetitionIndex), repetitionIndex, "Repetition index must be at
 142        }
 143
 1144        if (totalRepetitions < 1)
 145        {
 0146            throw new ArgumentOutOfRangeException(nameof(totalRepetitions), totalRepetitions, "Total repetitions must be
 147        }
 148
 1149        var paddingWidth = Math.Max(2, totalRepetitions.ToString(CultureInfo.InvariantCulture).Length);
 1150        var repetitionToken = repetitionIndex.ToString($"D{paddingWidth}", CultureInfo.InvariantCulture);
 1151        return $"{sourceItemId}__repeated-match__{sliceKey}__{repetitionToken}";
 152    }
 153
 154    public static string BuildRepeatedMatchSliceDatasetItemId(
 155        string sourceItemId,
 156        string sliceKey,
 157        int fixtureIndex,
 158        int totalFixtures,
 159        int repetitionIndex,
 160        int totalRepetitions)
 161    {
 1162        ArgumentException.ThrowIfNullOrWhiteSpace(sourceItemId);
 1163        ArgumentException.ThrowIfNullOrWhiteSpace(sliceKey);
 164
 1165        if (fixtureIndex < 1)
 166        {
 0167            throw new ArgumentOutOfRangeException(nameof(fixtureIndex), fixtureIndex, "Fixture index must be at least 1.
 168        }
 169
 1170        if (totalFixtures < 1)
 171        {
 0172            throw new ArgumentOutOfRangeException(nameof(totalFixtures), totalFixtures, "Total fixtures must be at least
 173        }
 174
 1175        if (repetitionIndex < 1)
 176        {
 0177            throw new ArgumentOutOfRangeException(nameof(repetitionIndex), repetitionIndex, "Repetition index must be at
 178        }
 179
 1180        if (totalRepetitions < 1)
 181        {
 0182            throw new ArgumentOutOfRangeException(nameof(totalRepetitions), totalRepetitions, "Total repetitions must be
 183        }
 184
 1185        var fixturePaddingWidth = Math.Max(2, totalFixtures.ToString(CultureInfo.InvariantCulture).Length);
 1186        var repetitionPaddingWidth = Math.Max(2, totalRepetitions.ToString(CultureInfo.InvariantCulture).Length);
 1187        var fixtureToken = fixtureIndex.ToString($"D{fixturePaddingWidth}", CultureInfo.InvariantCulture);
 1188        var repetitionToken = repetitionIndex.ToString($"D{repetitionPaddingWidth}", CultureInfo.InvariantCulture);
 1189        return $"{sourceItemId}__repeated-match-slice__{sliceKey}__m{fixtureToken}__{repetitionToken}";
 190    }
 191
 192    public static string BuildRepeatedMatchSourcePoolKey(int matchday, string homeTeam, string awayTeam)
 193    {
 1194        return $"md{matchday:00}-{Slugify(homeTeam)}-vs-{Slugify(awayTeam)}";
 195    }
 196
 197    public static string BuildSingleMatchSourcePoolKey(int matchday, string homeTeam, string awayTeam)
 198    {
 0199        return BuildRepeatedMatchSourcePoolKey(matchday, homeTeam, awayTeam);
 200    }
 201
 202    public static string ComputeSelectedItemIdsHash(IEnumerable<string> itemIds)
 203    {
 1204        ArgumentNullException.ThrowIfNull(itemIds);
 205
 1206        var joined = string.Join("\n", itemIds.OrderBy(itemId => itemId, StringComparer.Ordinal));
 1207        var hash = SHA256.HashData(Encoding.UTF8.GetBytes(joined));
 1208        return Convert.ToHexString(hash).ToLowerInvariant();
 209    }
 210
 211    public static string BuildRelativeEvaluationPolicyKey(EvaluationTimestampPolicy policy)
 212    {
 0213        ArgumentNullException.ThrowIfNull(policy);
 214
 0215        if (!string.Equals(policy.Kind, EvaluationTimestampPolicy.RelativeKind, StringComparison.OrdinalIgnoreCase)
 0216            || !string.Equals(policy.Reference, EvaluationTimestampPolicy.StartsAtReference, StringComparison.OrdinalIgn
 217        {
 0218            throw new ArgumentException("Only startsAt-relative evaluation policies can be converted to a policy key.", 
 219        }
 220
 0221        var timeSpan = policy.Offset.ToTimeSpan();
 0222        var sign = timeSpan.Ticks < 0 ? "-" : "+";
 0223        var absolute = timeSpan.Duration();
 0224        var parts = new List<string>();
 225
 0226        if (absolute.Days != 0)
 227        {
 0228            parts.Add($"{absolute.Days}d");
 229        }
 230
 0231        if (absolute.Hours != 0)
 232        {
 0233            parts.Add($"{absolute.Hours}h");
 234        }
 235
 0236        if (absolute.Minutes != 0)
 237        {
 0238            parts.Add($"{absolute.Minutes}m");
 239        }
 240
 0241        if (absolute.Seconds != 0)
 242        {
 0243            parts.Add($"{absolute.Seconds}s");
 244        }
 245
 0246        if (parts.Count == 0)
 247        {
 0248            parts.Add("0s");
 249        }
 250
 0251        return $"startsAt{sign}{string.Join(string.Empty, parts)}".ToLowerInvariant();
 252    }
 253
 254    public static string FormatStartedAtUtc(DateTimeOffset timestamp)
 255    {
 1256        return timestamp.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture);
 257    }
 258
 259    public static string Slugify(string value)
 260    {
 1261        ArgumentException.ThrowIfNullOrWhiteSpace(value);
 262
 1263        var normalized = value.Normalize(NormalizationForm.FormD);
 1264        var builder = new StringBuilder(normalized.Length);
 265
 1266        foreach (var character in normalized)
 267        {
 1268            if (CharUnicodeInfo.GetUnicodeCategory(character) == UnicodeCategory.NonSpacingMark)
 269            {
 270                continue;
 271            }
 272
 1273            if (char.IsLetterOrDigit(character))
 274            {
 1275                builder.Append(char.ToLowerInvariant(character));
 1276                continue;
 277            }
 278
 1279            if (builder.Length == 0 || builder[^1] == '-')
 280            {
 281                continue;
 282            }
 283
 1284            builder.Append('-');
 285        }
 286
 1287        return builder.ToString().Trim('-');
 288    }
 289}