< Summary

Information
Class: Orchestrator.Commands.Observability.AnalyzeMatch.AnalyzeMatchDetailedCommand
Assembly: Orchestrator
File(s): /home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Observability/AnalyzeMatch/AnalyzeMatchDetailedCommand.cs
Line coverage
97%
Covered lines: 145
Uncovered lines: 4
Coverable lines: 149
Total lines: 268
Line coverage: 97.3%
Branch coverage
94%
Covered branches: 51
Total branches: 54
Branch coverage: 94.4%
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()92.31%262693.94%
FormatCurrencyValue()100%11100%
FormatCurrencyOptional()100%22100%
FormatDurationValue()100%11100%
FormatDurationOptional()100%22100%
BuildSummary()95%2020100%
ExecuteRunsAsync()100%44100%
<ExecuteAsync()100%11100%
.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
 125    public AnalyzeMatchDetailedCommand(
 126        IAnsiConsole console,
 127        IFirebaseServiceFactory firebaseServiceFactory,
 128        IKicktippClientFactory kicktippClientFactory,
 129        IOpenAiServiceFactory openAiServiceFactory)
 30    {
 131        _console = console;
 132        _firebaseServiceFactory = firebaseServiceFactory;
 133        _kicktippClientFactory = kicktippClientFactory;
 134        _openAiServiceFactory = openAiServiceFactory;
 135    }
 36
 37    public override async Task<int> ExecuteAsync(CommandContext context, AnalyzeMatchDetailedSettings settings)
 38    {
 139        var loggerFactory = AnalyzeMatchCommandHelpers.CreateLoggerFactory(settings.Debug);
 140        var logger = loggerFactory.CreateLogger<AnalyzeMatchDetailedCommand>();
 41
 42        try
 43        {
 144            var validation = settings.Validate();
 145            if (!validation.Successful)
 46            {
 047                _console.MarkupLine($"[red]Error:[/] {validation.Message}");
 048                return 1;
 49            }
 50
 151            var predictionService = _openAiServiceFactory.CreatePredictionService(settings.Model);
 152            var tokenUsageTracker = _openAiServiceFactory.GetTokenUsageTracker();
 153            var contextRepository = _firebaseServiceFactory.CreateContextRepository();
 154            var kicktippClient = _kicktippClientFactory.CreateClient();
 55
 156            var communityContext = settings.CommunityContext!;
 57
 158            _console.MarkupLine($"[green]Analyze match initialized with model:[/] [yellow]{settings.Model}[/]");
 159            _console.MarkupLine($"[blue]Using community context:[/] [yellow]{communityContext}[/]");
 160            _console.MarkupLine($"[blue]Runs:[/] [yellow]{settings.Runs}[/]");
 61
 162            if (settings.Debug)
 63            {
 164                _console.MarkupLine("[dim]Debug logging enabled[/]");
 65            }
 66
 167            var match = await AnalyzeMatchCommandHelpers.ResolveMatchAsync(settings, kicktippClient, logger, communityCo
 168            if (match == null)
 69            {
 070                _console.MarkupLine("[red]Failed to resolve match details. Aborting.[/]");
 071                return 1;
 72            }
 73
 174            var contextDocuments = new List<DocumentContext>();
 75
 176            if (contextRepository != null)
 77            {
 178                var contextDocumentInfos = await AnalyzeMatchCommandHelpers.GetMatchContextDocumentsAsync(
 179                    contextRepository,
 180                    match.HomeTeam,
 181                    match.AwayTeam,
 182                    communityContext,
 183                    settings.Verbose,
 184                    _console);
 85
 186                contextDocuments = contextDocumentInfos.Select(info => info.Document).ToList();
 87
 188                if (contextDocumentInfos.Any())
 89                {
 190                    _console.MarkupLine("[dim]Loaded context documents:[/]");
 191                    foreach (var info in contextDocumentInfos)
 92                    {
 193                        _console.MarkupLine($"[grey]  • {info.Document.Name}[/] [dim](v{info.Version})[/]");
 94
 195                        if (settings.ShowContextDocuments)
 96                        {
 197                            var lines = info.Document.Content.Split('\n');
 198                            foreach (var line in lines.Take(10))
 99                            {
 1100                                _console.MarkupLine($"[grey]      {line.EscapeMarkup()}[/]");
 101                            }
 102
 1103                            if (lines.Length > 10)
 104                            {
 1105                                _console.MarkupLine($"[dim]      ... ({lines.Length - 10} more lines) ...[/]");
 106                            }
 107                        }
 108                    }
 109                }
 110                else
 111                {
 1112                    _console.MarkupLine("[yellow]No context documents retrieved; proceeding without additional context[/
 113                }
 114            }
 115            else
 116            {
 1117                _console.MarkupLine("[yellow]Context repository not configured. Proceeding without context documents.[/]
 118            }
 119
 1120            tokenUsageTracker.Reset();
 121
 1122            var predictions = new List<Prediction>();
 1123            var runMetrics = new List<RunMetric>();
 1124            var enableLiveEstimates = !settings.NoLiveEstimates;
 125
 1126            string FormatCurrencyValue(decimal value) => $"${value.ToString("F4", CultureInfo.InvariantCulture)}";
 1127            string FormatCurrencyOptional(decimal? value) => value.HasValue ? FormatCurrencyValue(value.Value) : "n/a";
 128
 1129            string FormatDurationValue(TimeSpan value) => value.ToString(@"hh\:mm\:ss\.fff", CultureInfo.InvariantCultur
 1130            string FormatDurationOptional(TimeSpan? value) => value.HasValue ? FormatDurationValue(value.Value) : "n/a";
 131
 132            IRenderable BuildSummary()
 133            {
 1134                var completedRuns = runMetrics.Count;
 1135                var remainingRuns = Math.Max(settings.Runs - completedRuns, 0);
 1136                var successfulRuns = runMetrics.Where(metric => metric.Success).ToList();
 1137                var costValues = successfulRuns
 1138                    .Where(metric => metric.Cost.HasValue)
 1139                    .Select(metric => metric.Cost!.Value)
 1140                    .ToList();
 141
 1142                var totalCostSoFar = costValues.Aggregate(0m, (sum, value) => sum + value);
 1143                decimal? averageCost = costValues.Count > 0 ? totalCostSoFar / costValues.Count : (decimal?)null;
 1144                decimal? projectedCost = averageCost.HasValue ? averageCost.Value * settings.Runs : (decimal?)null;
 145
 1146                var totalDuration = runMetrics.Aggregate(TimeSpan.Zero, (current, metric) => current + metric.Duration);
 1147                TimeSpan? averageDuration = runMetrics.Count > 0
 1148                    ? TimeSpan.FromTicks(totalDuration.Ticks / runMetrics.Count)
 1149                    : (TimeSpan?)null;
 1150                TimeSpan? estimatedRemaining = averageDuration.HasValue && remainingRuns > 0
 1151                    ? TimeSpan.FromTicks(averageDuration.Value.Ticks * remainingRuns)
 1152                    : (TimeSpan?)null;
 153
 1154                var table = new Table()
 1155                    .Title("[bold yellow]Live Estimates[/]")
 1156                    .Border(TableBorder.Rounded)
 1157                    .AddColumn(new TableColumn("[grey]Metric[/]").LeftAligned())
 1158                    .AddColumn(new TableColumn("[grey]Value[/]").LeftAligned());
 159
 1160                table.AddRow("Completed runs", $"{completedRuns}/{settings.Runs}");
 1161                table.AddRow("Successful predictions", $"{successfulRuns.Count}/{settings.Runs}");
 1162                table.AddRow("Total cost so far", FormatCurrencyValue(totalCostSoFar));
 1163                table.AddRow("Average cost", FormatCurrencyOptional(averageCost));
 1164                table.AddRow("Projected total cost", FormatCurrencyOptional(projectedCost));
 1165                table.AddRow("Average run time", FormatDurationOptional(averageDuration));
 1166                table.AddRow("Estimated remaining time", FormatDurationOptional(estimatedRemaining));
 1167                table.AddRow("Elapsed time", FormatDurationValue(totalDuration));
 168
 1169                return table;
 170            }
 171
 1172            Action refreshSummary = () => { };
 173
 174            async Task ExecuteRunsAsync()
 175            {
 1176                for (var run = 1; run <= settings.Runs; run++)
 177                {
 1178                    var stopwatch = Stopwatch.StartNew();
 179
 1180                    _console.MarkupLine($"[cyan]\nRun {run}/{settings.Runs}[/]");
 181
 1182                    var prediction = await predictionService.PredictMatchAsync(
 1183                        match,
 1184                        contextDocuments,
 1185                        includeJustification: true);
 186
 1187                    stopwatch.Stop();
 188
 1189                    if (prediction == null)
 190                    {
 1191                        _console.MarkupLine("[red]  ✗ Prediction failed[/]");
 1192                        runMetrics.Add(new RunMetric(run, stopwatch.Elapsed, false, null));
 1193                        refreshSummary();
 1194                        continue;
 195                    }
 196
 1197                    predictions.Add(prediction);
 198
 1199                    var lastCost = tokenUsageTracker.GetLastCost();
 1200                    var usageSummary = tokenUsageTracker.GetLastUsageCompactSummary();
 201
 1202                    runMetrics.Add(new RunMetric(run, stopwatch.Elapsed, true, lastCost));
 203
 1204                    _console.MarkupLine($"[green]  ✓ Prediction:[/] [yellow]{prediction.HomeGoals}:{prediction.AwayGoals
 205
 1206                    var justificationWriter = new JustificationConsoleWriter(_console);
 1207                    justificationWriter.WriteJustification(
 1208                        prediction.Justification,
 1209                        "[cyan]  ↳ Justification:[/]",
 1210                        "      ",
 1211                        "[yellow]  ↳ Justification: no explanation returned by model[/]");
 212
 1213                    _console.MarkupLine($"[magenta]  ↳ Cost:[/] [cyan]{FormatCurrencyValue(lastCost)}[/] [grey]({usageSu
 214
 1215                    refreshSummary();
 1216                }
 217
 1218                refreshSummary();
 1219            }
 220
 1221            if (enableLiveEstimates)
 222            {
 1223                await _console.Live(BuildSummary())
 1224                    .AutoClear(false)
 1225                    .StartAsync(async ctx =>
 1226                    {
 1227                        refreshSummary = () =>
 1228                        {
 1229                            ctx.UpdateTarget(BuildSummary());
 1230                            ctx.Refresh();
 1231                        };
 1232
 1233                        refreshSummary();
 1234                        await ExecuteRunsAsync();
 1235                    });
 236            }
 237            else
 238            {
 1239                await ExecuteRunsAsync();
 1240                _console.Write(BuildSummary());
 241            }
 242
 1243            if (predictions.Any())
 244            {
 1245                _console.MarkupLine($"\n[blue]Total runs with predictions:[/] [yellow]{predictions.Count}/{settings.Runs
 1246                _console.MarkupLine($"[blue]Total cost:[/] [yellow]{FormatCurrencyValue(tokenUsageTracker.GetTotalCost()
 247            }
 248            else
 249            {
 1250                _console.MarkupLine("[red]No successful predictions generated.[/]");
 251            }
 252
 1253            return 0;
 254        }
 1255        catch (Exception ex)
 256        {
 1257            logger.LogError(ex, "Error executing analyze-match command");
 1258            _console.MarkupLine($"[red]Error:[/] {ex.Message}");
 1259            return 1;
 260        }
 261        finally
 262        {
 1263            loggerFactory.Dispose();
 264        }
 1265    }
 266
 1267    private sealed record RunMetric(int RunNumber, TimeSpan Duration, bool Success, decimal? Cost);
 268}