< Summary

Information
Class: Orchestrator.Commands.Observability.ExportExperimentItem.ExportExperimentItemCommand
Assembly: Orchestrator
File(s): /home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Observability/ExportExperimentItem/ExportExperimentItemCommand.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 143
Coverable lines: 143
Total lines: 245
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 38
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%420200%
BuildExport(...)0%2040%
RehydrateForPromptOutput(...)100%210%
ResolveOutputPath(...)0%620%
BuildHostedDatasetItemId(...)100%210%
Slugify(...)0%110100%

File(s)

/home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Observability/ExportExperimentItem/ExportExperimentItemCommand.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.Commands.Observability;
 9using Orchestrator.Infrastructure.Factories;
 10using Spectre.Console;
 11using Spectre.Console.Cli;
 12
 13namespace Orchestrator.Commands.Observability.ExportExperimentItem;
 14
 15public sealed class ExportExperimentItemCommand : AsyncCommand<ExportExperimentItemSettings>
 16{
 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<ExportExperimentItemCommand> _logger;
 27
 028    public ExportExperimentItemCommand(
 029        IAnsiConsole console,
 030        IFirebaseServiceFactory firebaseServiceFactory,
 031        ILogger<ExportExperimentItemCommand> logger)
 32    {
 033        _console = console;
 034        _firebaseServiceFactory = firebaseServiceFactory;
 035        _logger = logger;
 036    }
 37
 38    public override async Task<int> ExecuteAsync(CommandContext context, ExportExperimentItemSettings settings)
 39    {
 40        try
 41        {
 042            _console.MarkupLine($"[green]Exporting experiment item for:[/] [yellow]{Markup.Escape(settings.HomeTeam)}[/]
 43
 044            var predictionRepository = _firebaseServiceFactory.CreatePredictionRepository();
 045            var contextRepository = _firebaseServiceFactory.CreateContextRepository();
 046            var matchOutcomeRepository = _firebaseServiceFactory.CreateMatchOutcomeRepository();
 47
 048            var reconstructionService = new MatchPromptReconstructionService(
 049                predictionRepository,
 050                contextRepository,
 051                new InstructionsTemplateProvider(PromptsFileProvider.Create()));
 52
 053            var evaluationTime = EvaluationTimeParser.ParseOrNull(settings.EvaluationTime);
 54
 055            var storedMatch = await predictionRepository.GetStoredMatchAsync(
 056                settings.HomeTeam,
 057                settings.AwayTeam,
 058                settings.Matchday!.Value,
 059                evaluationTime is null ? settings.Model : null,
 060                evaluationTime is null ? settings.CommunityContext : null);
 61
 062            if (storedMatch is null)
 63            {
 064                _console.MarkupLine($"[red]Stored match not found on matchday {settings.Matchday}:[/] {Markup.Escape(set
 065                return 1;
 66            }
 67
 068            var promptMatch = RehydrateForPromptOutput(storedMatch);
 69            ReconstructedMatchPredictionPrompt? reconstructedPrompt;
 070            if (evaluationTime is null)
 71            {
 072                reconstructedPrompt = await reconstructionService.ReconstructMatchPredictionPromptAsync(
 073                    promptMatch,
 074                    settings.Model,
 075                    settings.CommunityContext,
 076                    settings.WithJustification);
 77            }
 78            else
 79            {
 080                var selection = MatchContextDocumentCatalog.ForMatch(
 081                    promptMatch.HomeTeam,
 082                    promptMatch.AwayTeam,
 083                    settings.CommunityContext);
 84
 085                reconstructedPrompt = await reconstructionService.ReconstructMatchPredictionPromptAtTimestampAsync(
 086                    promptMatch,
 087                    settings.Model,
 088                    settings.CommunityContext,
 089                    evaluationTime.Value,
 090                    selection.RequiredDocumentNames,
 091                    selection.OptionalDocumentNames,
 092                    settings.WithJustification);
 93            }
 94
 095            if (reconstructedPrompt is null)
 96            {
 097                _console.MarkupLine("[red]No stored prediction metadata found for that match/model/community combination
 098                return 1;
 99            }
 100
 0101            var outcomes = await matchOutcomeRepository.GetMatchdayOutcomesAsync(
 0102                settings.Matchday.Value,
 0103                settings.CommunityContext);
 104
 0105            var outcome = outcomes.FirstOrDefault(candidate =>
 0106                string.Equals(candidate.HomeTeam, settings.HomeTeam, StringComparison.OrdinalIgnoreCase) &&
 0107                string.Equals(candidate.AwayTeam, settings.AwayTeam, StringComparison.OrdinalIgnoreCase));
 108
 0109            if (outcome is null)
 110            {
 0111                _console.MarkupLine("[red]No persisted match outcome was found for the selected match.[/]");
 0112                return 1;
 113            }
 114
 0115            if (!outcome.HasOutcome || outcome.HomeGoals is null || outcome.AwayGoals is null)
 116            {
 0117                _console.MarkupLine("[red]The selected match does not have a completed persisted outcome yet.[/]");
 0118                return 1;
 119            }
 120
 0121            var export = BuildExport(reconstructedPrompt, outcome);
 0122            var outputPath = ResolveOutputPath(settings, export.DatasetItem.Metadata);
 0123            Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
 124
 0125            await File.WriteAllTextAsync(
 0126                outputPath,
 0127                JsonSerializer.Serialize(export, OutputJsonOptions));
 128
 0129            _console.MarkupLine($"[green]Wrote experiment item:[/] [yellow]{Markup.Escape(outputPath)}[/]");
 0130            _console.MarkupLine($"[blue]Dataset item id:[/] {Markup.Escape(export.DatasetItem.Id)}");
 0131            _console.MarkupLine($"[blue]{(evaluationTime is null ? "Prediction timestamp" : "Reconstruction timestamp")}
 0132            _console.MarkupLine($"[blue]Outcome:[/] {outcome.HomeGoals}:{outcome.AwayGoals} ({outcome.Availability})");
 133
 0134            return 0;
 135        }
 0136        catch (Exception ex)
 137        {
 0138            _logger.LogError(ex, "Error exporting experiment item");
 0139            _console.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
 0140            return 1;
 141        }
 0142    }
 143
 144    private static ExportedExperimentItem BuildExport(
 145        ReconstructedMatchPredictionPrompt reconstructedPrompt,
 146        PersistedMatchOutcome outcome)
 147    {
 0148        using var matchJsonDocument = JsonDocument.Parse(reconstructedPrompt.MatchJson);
 0149        var tippSpielId = outcome.TippSpielId ?? throw new InvalidOperationException(
 0150            $"Persisted outcome for {outcome.HomeTeam} vs {outcome.AwayTeam} is missing tippspielId.");
 151
 0152        var metadata = new MatchExperimentMetadata(
 0153            reconstructedPrompt.CommunityContext,
 0154            outcome.Competition,
 0155            reconstructedPrompt.Match.Matchday,
 0156            reconstructedPrompt.Match.HomeTeam,
 0157            reconstructedPrompt.Match.AwayTeam,
 0158            tippSpielId,
 0159            reconstructedPrompt.Model,
 0160            reconstructedPrompt.IncludeJustification,
 0161            reconstructedPrompt.PromptTimestamp,
 0162            reconstructedPrompt.PromptTemplatePath,
 0163            reconstructedPrompt.ContextDocumentNames,
 0164            reconstructedPrompt.ResolvedContextDocuments
 0165                .Select(document => new MatchExperimentResolvedContextDocument(
 0166                    document.DocumentName,
 0167                    document.Version,
 0168                    document.CreatedAt))
 0169                .ToList()
 0170                .AsReadOnly(),
 0171            new MatchExperimentHistoricalPrediction(
 0172                outcome.HomeGoals!.Value,
 0173                outcome.AwayGoals!.Value));
 174
 0175        return new ExportedExperimentItem(
 0176            new MatchExperimentDatasetItem(
 0177                BuildHostedDatasetItemId(outcome.Competition, outcome.CommunityContext, tippSpielId),
 0178                matchJsonDocument.RootElement.Clone(),
 0179                new MatchExperimentExpectedOutput(
 0180                    outcome.HomeGoals!.Value,
 0181                    outcome.AwayGoals!.Value,
 0182                    outcome.Availability.ToString()),
 0183                metadata),
 0184            new MatchExperimentRunnerPayload(
 0185                reconstructedPrompt.SystemPrompt,
 0186                reconstructedPrompt.MatchJson));
 0187    }
 188
 189    private static Match RehydrateForPromptOutput(Match match)
 190    {
 0191        var instant = match.StartsAt.ToInstant();
 0192        var offset = BundesligaTimeZone.GetUtcOffset(instant);
 0193        var localizedStartsAt = instant.InZone(DateTimeZone.ForOffset(offset));
 0194        return new Match(match.HomeTeam, match.AwayTeam, localizedStartsAt, match.Matchday, match.IsCancelled);
 195    }
 196
 197    private static string ResolveOutputPath(ExportExperimentItemSettings settings, MatchExperimentMetadata metadata)
 198    {
 0199        if (!string.IsNullOrWhiteSpace(settings.OutputPath))
 200        {
 0201            return Path.GetFullPath(settings.OutputPath);
 202        }
 203
 0204        var fileName = $"{metadata.Matchday:00}-{Slugify(metadata.HomeTeam)}-vs-{Slugify(metadata.AwayTeam)}-{Slugify(me
 0205        return Path.GetFullPath(Path.Combine("artifacts", "langfuse-runner-spike", fileName));
 206    }
 207
 208    private static string BuildHostedDatasetItemId(string competition, string communityContext, string tippSpielId)
 209    {
 0210        return string.Join(
 0211            "__",
 0212            Slugify(competition),
 0213            Slugify(communityContext),
 0214            $"ts{Slugify(tippSpielId)}");
 215    }
 216
 217    private static string Slugify(string value)
 218    {
 0219        var normalized = value.Normalize(NormalizationForm.FormD);
 0220        var builder = new StringBuilder(normalized.Length);
 221
 0222        foreach (var character in normalized)
 223        {
 0224            if (CharUnicodeInfo.GetUnicodeCategory(character) == UnicodeCategory.NonSpacingMark)
 225            {
 226                continue;
 227            }
 228
 0229            if (char.IsLetterOrDigit(character))
 230            {
 0231                builder.Append(char.ToLowerInvariant(character));
 0232                continue;
 233            }
 234
 0235            if (builder.Length == 0 || builder[^1] == '-')
 236            {
 237                continue;
 238            }
 239
 0240            builder.Append('-');
 241        }
 242
 0243        return builder.ToString().Trim('-');
 244    }
 245}