< Summary

Information
Class: Orchestrator.Commands.Observability.AnalyzeMatch.AnalyzeMatchDetailedCommand.RunMetric
Assembly: Orchestrator
File(s): /home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Observability/AnalyzeMatch/AnalyzeMatchDetailedCommand.cs
Line coverage
100%
Covered lines: 1
Uncovered lines: 0
Coverable lines: 1
Total lines: 268
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_RunNumber()100%210%
set_RunNumber(...)100%210%
get_Duration()100%11100%
set_Duration(...)100%210%
get_Success()100%11100%
set_Success(...)100%210%
get_Cost()100%11100%
set_Cost(...)100%210%
.ctor(...)100%210%

File(s)

/home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Observability/AnalyzeMatch/AnalyzeMatchDetailedCommand.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Diagnostics;
 4using System.Globalization;
 5using System.Linq;
 6using System.Threading.Tasks;
 7using EHonda.KicktippAi.Core;
 8using Microsoft.Extensions.Logging;
 9using Spectre.Console;
 10using Spectre.Console.Cli;
 11using Spectre.Console.Rendering;
 12using OpenAiIntegration;
 13using Orchestrator.Commands.Shared;
 14using Orchestrator.Infrastructure.Factories;
 15
 16namespace Orchestrator.Commands.Observability.AnalyzeMatch;
 17
 18public class AnalyzeMatchDetailedCommand : AsyncCommand<AnalyzeMatchDetailedSettings>
 19{
 20    private readonly IAnsiConsole _console;
 21    private readonly IFirebaseServiceFactory _firebaseServiceFactory;
 22    private readonly IKicktippClientFactory _kicktippClientFactory;
 23    private readonly IOpenAiServiceFactory _openAiServiceFactory;
 24
 25    public AnalyzeMatchDetailedCommand(
 26        IAnsiConsole console,
 27        IFirebaseServiceFactory firebaseServiceFactory,
 28        IKicktippClientFactory kicktippClientFactory,
 29        IOpenAiServiceFactory openAiServiceFactory)
 30    {
 31        _console = console;
 32        _firebaseServiceFactory = firebaseServiceFactory;
 33        _kicktippClientFactory = kicktippClientFactory;
 34        _openAiServiceFactory = openAiServiceFactory;
 35    }
 36
 37    public override async Task<int> ExecuteAsync(CommandContext context, AnalyzeMatchDetailedSettings settings)
 38    {
 39        var loggerFactory = AnalyzeMatchCommandHelpers.CreateLoggerFactory(settings.Debug);
 40        var logger = loggerFactory.CreateLogger<AnalyzeMatchDetailedCommand>();
 41
 42        try
 43        {
 44            var validation = settings.Validate();
 45            if (!validation.Successful)
 46            {
 47                _console.MarkupLine($"[red]Error:[/] {validation.Message}");
 48                return 1;
 49            }
 50
 51            var predictionService = _openAiServiceFactory.CreatePredictionService(settings.Model);
 52            var tokenUsageTracker = _openAiServiceFactory.GetTokenUsageTracker();
 53            var contextRepository = _firebaseServiceFactory.CreateContextRepository();
 54            var kicktippClient = _kicktippClientFactory.CreateClient();
 55
 56            var communityContext = settings.CommunityContext!;
 57
 58            _console.MarkupLine($"[green]Analyze match initialized with model:[/] [yellow]{settings.Model}[/]");
 59            _console.MarkupLine($"[blue]Using community context:[/] [yellow]{communityContext}[/]");
 60            _console.MarkupLine($"[blue]Runs:[/] [yellow]{settings.Runs}[/]");
 61
 62            if (settings.Debug)
 63            {
 64                _console.MarkupLine("[dim]Debug logging enabled[/]");
 65            }
 66
 67            var match = await AnalyzeMatchCommandHelpers.ResolveMatchAsync(settings, kicktippClient, logger, communityCo
 68            if (match == null)
 69            {
 70                _console.MarkupLine("[red]Failed to resolve match details. Aborting.[/]");
 71                return 1;
 72            }
 73
 74            var contextDocuments = new List<DocumentContext>();
 75
 76            if (contextRepository != null)
 77            {
 78                var contextDocumentInfos = await AnalyzeMatchCommandHelpers.GetMatchContextDocumentsAsync(
 79                    contextRepository,
 80                    match.HomeTeam,
 81                    match.AwayTeam,
 82                    communityContext,
 83                    settings.Verbose,
 84                    _console);
 85
 86                contextDocuments = contextDocumentInfos.Select(info => info.Document).ToList();
 87
 88                if (contextDocumentInfos.Any())
 89                {
 90                    _console.MarkupLine("[dim]Loaded context documents:[/]");
 91                    foreach (var info in contextDocumentInfos)
 92                    {
 93                        _console.MarkupLine($"[grey]  • {info.Document.Name}[/] [dim](v{info.Version})[/]");
 94
 95                        if (settings.ShowContextDocuments)
 96                        {
 97                            var lines = info.Document.Content.Split('\n');
 98                            foreach (var line in lines.Take(10))
 99                            {
 100                                _console.MarkupLine($"[grey]      {line.EscapeMarkup()}[/]");
 101                            }
 102
 103                            if (lines.Length > 10)
 104                            {
 105                                _console.MarkupLine($"[dim]      ... ({lines.Length - 10} more lines) ...[/]");
 106                            }
 107                        }
 108                    }
 109                }
 110                else
 111                {
 112                    _console.MarkupLine("[yellow]No context documents retrieved; proceeding without additional context[/
 113                }
 114            }
 115            else
 116            {
 117                _console.MarkupLine("[yellow]Context repository not configured. Proceeding without context documents.[/]
 118            }
 119
 120            tokenUsageTracker.Reset();
 121
 122            var predictions = new List<Prediction>();
 123            var runMetrics = new List<RunMetric>();
 124            var enableLiveEstimates = !settings.NoLiveEstimates;
 125
 126            string FormatCurrencyValue(decimal value) => $"${value.ToString("F4", CultureInfo.InvariantCulture)}";
 127            string FormatCurrencyOptional(decimal? value) => value.HasValue ? FormatCurrencyValue(value.Value) : "n/a";
 128
 129            string FormatDurationValue(TimeSpan value) => value.ToString(@"hh\:mm\:ss\.fff", CultureInfo.InvariantCultur
 130            string FormatDurationOptional(TimeSpan? value) => value.HasValue ? FormatDurationValue(value.Value) : "n/a";
 131
 132            IRenderable BuildSummary()
 133            {
 134                var completedRuns = runMetrics.Count;
 135                var remainingRuns = Math.Max(settings.Runs - completedRuns, 0);
 136                var successfulRuns = runMetrics.Where(metric => metric.Success).ToList();
 137                var costValues = successfulRuns
 138                    .Where(metric => metric.Cost.HasValue)
 139                    .Select(metric => metric.Cost!.Value)
 140                    .ToList();
 141
 142                var totalCostSoFar = costValues.Aggregate(0m, (sum, value) => sum + value);
 143                decimal? averageCost = costValues.Count > 0 ? totalCostSoFar / costValues.Count : (decimal?)null;
 144                decimal? projectedCost = averageCost.HasValue ? averageCost.Value * settings.Runs : (decimal?)null;
 145
 146                var totalDuration = runMetrics.Aggregate(TimeSpan.Zero, (current, metric) => current + metric.Duration);
 147                TimeSpan? averageDuration = runMetrics.Count > 0
 148                    ? TimeSpan.FromTicks(totalDuration.Ticks / runMetrics.Count)
 149                    : (TimeSpan?)null;
 150                TimeSpan? estimatedRemaining = averageDuration.HasValue && remainingRuns > 0
 151                    ? TimeSpan.FromTicks(averageDuration.Value.Ticks * remainingRuns)
 152                    : (TimeSpan?)null;
 153
 154                var table = new Table()
 155                    .Title("[bold yellow]Live Estimates[/]")
 156                    .Border(TableBorder.Rounded)
 157                    .AddColumn(new TableColumn("[grey]Metric[/]").LeftAligned())
 158                    .AddColumn(new TableColumn("[grey]Value[/]").LeftAligned());
 159
 160                table.AddRow("Completed runs", $"{completedRuns}/{settings.Runs}");
 161                table.AddRow("Successful predictions", $"{successfulRuns.Count}/{settings.Runs}");
 162                table.AddRow("Total cost so far", FormatCurrencyValue(totalCostSoFar));
 163                table.AddRow("Average cost", FormatCurrencyOptional(averageCost));
 164                table.AddRow("Projected total cost", FormatCurrencyOptional(projectedCost));
 165                table.AddRow("Average run time", FormatDurationOptional(averageDuration));
 166                table.AddRow("Estimated remaining time", FormatDurationOptional(estimatedRemaining));
 167                table.AddRow("Elapsed time", FormatDurationValue(totalDuration));
 168
 169                return table;
 170            }
 171
 172            Action refreshSummary = () => { };
 173
 174            async Task ExecuteRunsAsync()
 175            {
 176                for (var run = 1; run <= settings.Runs; run++)
 177                {
 178                    var stopwatch = Stopwatch.StartNew();
 179
 180                    _console.MarkupLine($"[cyan]\nRun {run}/{settings.Runs}[/]");
 181
 182                    var prediction = await predictionService.PredictMatchAsync(
 183                        match,
 184                        contextDocuments,
 185                        includeJustification: true);
 186
 187                    stopwatch.Stop();
 188
 189                    if (prediction == null)
 190                    {
 191                        _console.MarkupLine("[red]  ✗ Prediction failed[/]");
 192                        runMetrics.Add(new RunMetric(run, stopwatch.Elapsed, false, null));
 193                        refreshSummary();
 194                        continue;
 195                    }
 196
 197                    predictions.Add(prediction);
 198
 199                    var lastCost = tokenUsageTracker.GetLastCost();
 200                    var usageSummary = tokenUsageTracker.GetLastUsageCompactSummary();
 201
 202                    runMetrics.Add(new RunMetric(run, stopwatch.Elapsed, true, lastCost));
 203
 204                    _console.MarkupLine($"[green]  ✓ Prediction:[/] [yellow]{prediction.HomeGoals}:{prediction.AwayGoals
 205
 206                    var justificationWriter = new JustificationConsoleWriter(_console);
 207                    justificationWriter.WriteJustification(
 208                        prediction.Justification,
 209                        "[cyan]  ↳ Justification:[/]",
 210                        "      ",
 211                        "[yellow]  ↳ Justification: no explanation returned by model[/]");
 212
 213                    _console.MarkupLine($"[magenta]  ↳ Cost:[/] [cyan]{FormatCurrencyValue(lastCost)}[/] [grey]({usageSu
 214
 215                    refreshSummary();
 216                }
 217
 218                refreshSummary();
 219            }
 220
 221            if (enableLiveEstimates)
 222            {
 223                await _console.Live(BuildSummary())
 224                    .AutoClear(false)
 225                    .StartAsync(async ctx =>
 226                    {
 227                        refreshSummary = () =>
 228                        {
 229                            ctx.UpdateTarget(BuildSummary());
 230                            ctx.Refresh();
 231                        };
 232
 233                        refreshSummary();
 234                        await ExecuteRunsAsync();
 235                    });
 236            }
 237            else
 238            {
 239                await ExecuteRunsAsync();
 240                _console.Write(BuildSummary());
 241            }
 242
 243            if (predictions.Any())
 244            {
 245                _console.MarkupLine($"\n[blue]Total runs with predictions:[/] [yellow]{predictions.Count}/{settings.Runs
 246                _console.MarkupLine($"[blue]Total cost:[/] [yellow]{FormatCurrencyValue(tokenUsageTracker.GetTotalCost()
 247            }
 248            else
 249            {
 250                _console.MarkupLine("[red]No successful predictions generated.[/]");
 251            }
 252
 253            return 0;
 254        }
 255        catch (Exception ex)
 256        {
 257            logger.LogError(ex, "Error executing analyze-match command");
 258            _console.MarkupLine($"[red]Error:[/] {ex.Message}");
 259            return 1;
 260        }
 261        finally
 262        {
 263            loggerFactory.Dispose();
 264        }
 265    }
 266
 1267    private sealed record RunMetric(int RunNumber, TimeSpan Duration, bool Success, decimal? Cost);
 268}