| | | 1 | | using System.Globalization; |
| | | 2 | | using EHonda.KicktippAi.Core; |
| | | 3 | | using Microsoft.Extensions.Logging; |
| | | 4 | | using NodaTime; |
| | | 5 | | using Orchestrator.Infrastructure; |
| | | 6 | | using Orchestrator.Infrastructure.Factories; |
| | | 7 | | using Spectre.Console; |
| | | 8 | | using Spectre.Console.Cli; |
| | | 9 | | |
| | | 10 | | namespace Orchestrator.Commands.Operations.Wm26RecentHistory; |
| | | 11 | | |
| | | 12 | | public sealed class Wm26RecentHistoryApplyDateMapCommand |
| | | 13 | | : AsyncCommand<Wm26RecentHistoryApplyDateMapSettings> |
| | | 14 | | { |
| | | 15 | | private static readonly DateTimeZone BerlinTimeZone = DateTimeZoneProviders.Tzdb["Europe/Berlin"]; |
| | | 16 | | |
| | | 17 | | private readonly IAnsiConsole _console; |
| | | 18 | | private readonly IFirebaseServiceFactory _firebaseServiceFactory; |
| | | 19 | | private readonly ILogger<Wm26RecentHistoryApplyDateMapCommand> _logger; |
| | | 20 | | |
| | | 21 | | public Wm26RecentHistoryApplyDateMapCommand( |
| | | 22 | | IAnsiConsole console, |
| | | 23 | | IFirebaseServiceFactory firebaseServiceFactory, |
| | | 24 | | ILogger<Wm26RecentHistoryApplyDateMapCommand> logger) |
| | | 25 | | { |
| | | 26 | | _console = console; |
| | | 27 | | _firebaseServiceFactory = firebaseServiceFactory; |
| | | 28 | | _logger = logger; |
| | | 29 | | } |
| | | 30 | | |
| | | 31 | | protected override async Task<int> ExecuteAsync( |
| | | 32 | | CommandContext context, |
| | | 33 | | Wm26RecentHistoryApplyDateMapSettings settings, |
| | | 34 | | CancellationToken cancellationToken) |
| | | 35 | | { |
| | | 36 | | return await ExecuteWithSettingsAsync(settings, cancellationToken); |
| | | 37 | | } |
| | | 38 | | |
| | | 39 | | internal async Task<int> ExecuteWithSettingsAsync( |
| | | 40 | | Wm26RecentHistoryApplyDateMapSettings settings, |
| | | 41 | | CancellationToken cancellationToken = default) |
| | | 42 | | { |
| | | 43 | | try |
| | | 44 | | { |
| | | 45 | | if (!File.Exists(settings.Input)) |
| | | 46 | | { |
| | | 47 | | _console.MarkupLine($"[red]Date map not found:[/] {settings.Input}"); |
| | | 48 | | return 1; |
| | | 49 | | } |
| | | 50 | | |
| | | 51 | | var dateMapContent = await File.ReadAllTextAsync(settings.Input, cancellationToken); |
| | | 52 | | var dateMapEntries = HistoryCsvUtility.ReadDateMapEntries(dateMapContent); |
| | | 53 | | if (dateMapEntries.Count == 0) |
| | | 54 | | { |
| | | 55 | | _console.MarkupLine("[red]Date map has no rows[/]"); |
| | | 56 | | return 1; |
| | | 57 | | } |
| | | 58 | | |
| | | 59 | | var competition = CompetitionResolver.ResolveCompetition( |
| | | 60 | | settings.Competition, |
| | | 61 | | communityContext: settings.CommunityContext); |
| | | 62 | | var repositoryCompetition = CompetitionResolver.ToRepositoryCompetitionArgument(competition); |
| | | 63 | | var contextRepository = _firebaseServiceFactory.CreateContextRepository(repositoryCompetition); |
| | | 64 | | var applyOptions = CreateApplyOptions(settings); |
| | | 65 | | var predictionRepository = settings.ApplyKnownOnly && applyOptions.PreserveCollectedOnOrAfter.HasValue |
| | | 66 | | ? _firebaseServiceFactory.CreatePredictionRepository(repositoryCompetition) |
| | | 67 | | : null; |
| | | 68 | | var predictionLookupCache = new Dictionary<PredictionLookupKey, Match?>(); |
| | | 69 | | |
| | | 70 | | _console.MarkupLine($"[green]Applying WM26 recent-history date map for:[/] [yellow]{settings.CommunityContex |
| | | 71 | | _console.MarkupLine($"[blue]Using competition:[/] [yellow]{competition}[/]"); |
| | | 72 | | if (settings.ApplyKnownOnly) |
| | | 73 | | { |
| | | 74 | | _console.MarkupLine("[blue]Apply-known-only mode enabled - unmapped rows will be preserved[/]"); |
| | | 75 | | if (applyOptions.PreserveCollectedOnOrAfter.HasValue) |
| | | 76 | | { |
| | | 77 | | _console.MarkupLine( |
| | | 78 | | $"[blue]Resolving WM tournament date-only rows dated on or after from stored predictions:[/] [ye |
| | | 79 | | } |
| | | 80 | | } |
| | | 81 | | |
| | | 82 | | if (settings.DryRun) |
| | | 83 | | { |
| | | 84 | | _console.MarkupLine("[magenta]Dry run mode enabled - no Firestore documents will be written[/]"); |
| | | 85 | | } |
| | | 86 | | |
| | | 87 | | var documentNames = await contextRepository.GetContextDocumentNamesAsync( |
| | | 88 | | settings.CommunityContext, |
| | | 89 | | cancellationToken); |
| | | 90 | | var historyDocumentNames = documentNames |
| | | 91 | | .Where(IsRecentHistoryDocument) |
| | | 92 | | .OrderBy(name => name, StringComparer.Ordinal) |
| | | 93 | | .ToList(); |
| | | 94 | | |
| | | 95 | | if (historyDocumentNames.Count == 0) |
| | | 96 | | { |
| | | 97 | | _console.MarkupLine("[yellow]No recent-history documents found[/]"); |
| | | 98 | | return settings.ApplyKnownOnly ? 0 : 1; |
| | | 99 | | } |
| | | 100 | | |
| | | 101 | | var plannedUpdates = new List<PlannedUpdate>(); |
| | | 102 | | var missingEntries = new List<HistoryDateMapEntry>(); |
| | | 103 | | var missingPredictionEntries = new List<HistoryDateMapEntry>(); |
| | | 104 | | |
| | | 105 | | foreach (var documentName in historyDocumentNames) |
| | | 106 | | { |
| | | 107 | | var document = await contextRepository.GetLatestContextDocumentAsync( |
| | | 108 | | documentName, |
| | | 109 | | settings.CommunityContext, |
| | | 110 | | cancellationToken); |
| | | 111 | | if (document is null) |
| | | 112 | | { |
| | | 113 | | continue; |
| | | 114 | | } |
| | | 115 | | |
| | | 116 | | var predictionDateEntries = await BuildPredictionDateEntriesAsync( |
| | | 117 | | documentName, |
| | | 118 | | document.Content, |
| | | 119 | | applyOptions, |
| | | 120 | | predictionRepository, |
| | | 121 | | settings.CommunityContext, |
| | | 122 | | predictionLookupCache, |
| | | 123 | | cancellationToken); |
| | | 124 | | var documentApplyOptions = applyOptions with { PredictionDateEntries = predictionDateEntries }; |
| | | 125 | | var result = HistoryCsvUtility.ApplyDateMap(documentName, document.Content, dateMapEntries, documentAppl |
| | | 126 | | if (result.MissingEntries.Count > 0) |
| | | 127 | | { |
| | | 128 | | missingEntries.AddRange(result.MissingEntries); |
| | | 129 | | } |
| | | 130 | | |
| | | 131 | | if (result.MissingPredictionEntries.Count > 0) |
| | | 132 | | { |
| | | 133 | | missingPredictionEntries.AddRange(result.MissingPredictionEntries); |
| | | 134 | | } |
| | | 135 | | |
| | | 136 | | plannedUpdates.Add(new PlannedUpdate(documentName, document, result)); |
| | | 137 | | |
| | | 138 | | if (settings.Verbose) |
| | | 139 | | { |
| | | 140 | | _console.MarkupLine( |
| | | 141 | | $"[dim] Checked {documentName}: {result.RowCount} row(s), " + |
| | | 142 | | $"{result.UpdatedRowCount} updated, {result.PreservedRowCount} preserved, " + |
| | | 143 | | $"{result.SkippedRowCount} skipped[/]"); |
| | | 144 | | } |
| | | 145 | | } |
| | | 146 | | |
| | | 147 | | if (missingEntries.Count > 0) |
| | | 148 | | { |
| | | 149 | | PrintMissingEntries(missingEntries); |
| | | 150 | | return 1; |
| | | 151 | | } |
| | | 152 | | |
| | | 153 | | if (missingPredictionEntries.Count > 0) |
| | | 154 | | { |
| | | 155 | | PrintMissingPredictionEntries(missingPredictionEntries); |
| | | 156 | | return 1; |
| | | 157 | | } |
| | | 158 | | |
| | | 159 | | var savedCount = 0; |
| | | 160 | | var unchangedCount = 0; |
| | | 161 | | |
| | | 162 | | foreach (var update in plannedUpdates) |
| | | 163 | | { |
| | | 164 | | if (update.Document.Content == update.Result.Content) |
| | | 165 | | { |
| | | 166 | | unchangedCount++; |
| | | 167 | | if (settings.Verbose) |
| | | 168 | | { |
| | | 169 | | _console.MarkupLine($"[dim] Skipped {update.DocumentName} (content unchanged)[/]"); |
| | | 170 | | } |
| | | 171 | | |
| | | 172 | | continue; |
| | | 173 | | } |
| | | 174 | | |
| | | 175 | | if (settings.DryRun) |
| | | 176 | | { |
| | | 177 | | _console.MarkupLine($"[magenta] Dry run - would save:[/] {update.DocumentName}"); |
| | | 178 | | savedCount++; |
| | | 179 | | continue; |
| | | 180 | | } |
| | | 181 | | |
| | | 182 | | var savedVersion = await contextRepository.SaveContextDocumentAsync( |
| | | 183 | | update.DocumentName, |
| | | 184 | | update.Result.Content, |
| | | 185 | | settings.CommunityContext, |
| | | 186 | | cancellationToken); |
| | | 187 | | |
| | | 188 | | if (savedVersion.HasValue) |
| | | 189 | | { |
| | | 190 | | savedCount++; |
| | | 191 | | if (settings.Verbose) |
| | | 192 | | { |
| | | 193 | | _console.MarkupLine($"[green] Saved {update.DocumentName} as version {savedVersion.Value}[/]"); |
| | | 194 | | } |
| | | 195 | | } |
| | | 196 | | else |
| | | 197 | | { |
| | | 198 | | unchangedCount++; |
| | | 199 | | if (settings.Verbose) |
| | | 200 | | { |
| | | 201 | | _console.MarkupLine($"[dim] Skipped {update.DocumentName} (repository reported unchanged)[/]"); |
| | | 202 | | } |
| | | 203 | | } |
| | | 204 | | } |
| | | 205 | | |
| | | 206 | | var completionMessage = settings.DryRun |
| | | 207 | | ? $"[magenta]Date-map dry run completed - {savedCount} document(s) would be saved[/]" |
| | | 208 | | : $"[green]Date-map apply completed - saved {savedCount} document(s)[/]"; |
| | | 209 | | _console.MarkupLine(completionMessage); |
| | | 210 | | _console.MarkupLine($"[dim]Unchanged: {unchangedCount} document(s)[/]"); |
| | | 211 | | |
| | | 212 | | return 0; |
| | | 213 | | } |
| | | 214 | | catch (Exception ex) |
| | | 215 | | { |
| | | 216 | | _logger.LogError(ex, "Failed to apply WM26 recent-history date map"); |
| | | 217 | | _console.MarkupLine($"[red]Error:[/] {ex.Message}"); |
| | | 218 | | return 1; |
| | | 219 | | } |
| | | 220 | | } |
| | | 221 | | |
| | | 222 | | private static HistoryDateMapApplyOptions CreateApplyOptions(Wm26RecentHistoryApplyDateMapSettings settings) |
| | | 223 | | { |
| | | 224 | | DateOnly? preserveCollectedOnOrAfter = null; |
| | | 225 | | if (!string.IsNullOrWhiteSpace(settings.PreserveCollectedOnOrAfter)) |
| | | 226 | | { |
| | | 227 | | preserveCollectedOnOrAfter = DateOnly.ParseExact( |
| | | 228 | | settings.PreserveCollectedOnOrAfter.Trim(), |
| | | 229 | | "yyyy-MM-dd", |
| | | 230 | | CultureInfo.InvariantCulture); |
| | | 231 | | } |
| | | 232 | | |
| | | 233 | | return new HistoryDateMapApplyOptions( |
| | | 234 | | settings.ApplyKnownOnly, |
| | | 235 | | preserveCollectedOnOrAfter); |
| | | 236 | | } |
| | | 237 | | |
| | | 238 | | private async Task<IReadOnlyList<HistoryDateMapEntry>> BuildPredictionDateEntriesAsync( |
| | | 239 | | string documentName, |
| | | 240 | | string content, |
| | | 241 | | HistoryDateMapApplyOptions applyOptions, |
| | | 242 | | IPredictionRepository? predictionRepository, |
| | | 243 | | string communityContext, |
| | | 244 | | Dictionary<PredictionLookupKey, Match?> predictionLookupCache, |
| | | 245 | | CancellationToken cancellationToken) |
| | | 246 | | { |
| | | 247 | | if (!applyOptions.PreserveCollectedOnOrAfter.HasValue || predictionRepository is null) |
| | | 248 | | { |
| | | 249 | | return Array.Empty<HistoryDateMapEntry>(); |
| | | 250 | | } |
| | | 251 | | |
| | | 252 | | var rows = HistoryCsvUtility.ExtractRowsRequiringPredictionPlayedAt( |
| | | 253 | | documentName, |
| | | 254 | | content, |
| | | 255 | | applyOptions.PreserveCollectedOnOrAfter.Value); |
| | | 256 | | if (rows.Count == 0) |
| | | 257 | | { |
| | | 258 | | return Array.Empty<HistoryDateMapEntry>(); |
| | | 259 | | } |
| | | 260 | | |
| | | 261 | | var entries = new List<HistoryDateMapEntry>(); |
| | | 262 | | foreach (var row in rows) |
| | | 263 | | { |
| | | 264 | | var lookupKey = new PredictionLookupKey(row.HomeTeam.Trim(), row.AwayTeam.Trim()); |
| | | 265 | | if (!predictionLookupCache.TryGetValue(lookupKey, out var match)) |
| | | 266 | | { |
| | | 267 | | match = await predictionRepository.GetLatestPredictedMatchByTeamsAsync( |
| | | 268 | | row.HomeTeam, |
| | | 269 | | row.AwayTeam, |
| | | 270 | | communityContext, |
| | | 271 | | cancellationToken); |
| | | 272 | | predictionLookupCache[lookupKey] = match; |
| | | 273 | | } |
| | | 274 | | |
| | | 275 | | if (match is null) |
| | | 276 | | { |
| | | 277 | | continue; |
| | | 278 | | } |
| | | 279 | | |
| | | 280 | | entries.Add(row with { PlayedAt = FormatPlayedAt(match.StartsAt) }); |
| | | 281 | | } |
| | | 282 | | |
| | | 283 | | return entries; |
| | | 284 | | } |
| | | 285 | | |
| | | 286 | | private static string FormatPlayedAt(ZonedDateTime startsAt) |
| | | 287 | | { |
| | | 288 | | var local = startsAt.WithZone(BerlinTimeZone); |
| | | 289 | | var dateTimeOffset = new DateTimeOffset( |
| | | 290 | | local.LocalDateTime.ToDateTimeUnspecified(), |
| | | 291 | | local.Offset.ToTimeSpan()); |
| | | 292 | | |
| | | 293 | | return dateTimeOffset.ToString("yyyy-MM-dd'T'HH:mm:sszzz", CultureInfo.InvariantCulture); |
| | | 294 | | } |
| | | 295 | | |
| | | 296 | | private void PrintMissingEntries(IReadOnlyList<HistoryDateMapEntry> missingEntries) |
| | | 297 | | { |
| | | 298 | | _console.MarkupLine($"[red]Date map is missing exact Played_At values for {missingEntries.Count} row(s)[/]"); |
| | | 299 | | |
| | | 300 | | foreach (var entry in missingEntries.Take(20)) |
| | | 301 | | { |
| | | 302 | | _console.MarkupLine( |
| | | 303 | | "[red] Missing:[/] " + |
| | | 304 | | $"{entry.DocumentName} | {entry.Competition} | {entry.HomeTeam} vs {entry.AwayTeam} | " + |
| | | 305 | | $"{entry.Score} | {entry.Annotation}"); |
| | | 306 | | } |
| | | 307 | | |
| | | 308 | | if (missingEntries.Count > 20) |
| | | 309 | | { |
| | | 310 | | _console.MarkupLine($"[dim] ... and {missingEntries.Count - 20} more[/]"); |
| | | 311 | | } |
| | | 312 | | } |
| | | 313 | | |
| | | 314 | | private void PrintMissingPredictionEntries(IReadOnlyList<HistoryDateMapEntry> missingEntries) |
| | | 315 | | { |
| | | 316 | | _console.MarkupLine($"[red]Missing stored predictions for {missingEntries.Count} unmapped WM tournament recent-h |
| | | 317 | | |
| | | 318 | | foreach (var entry in missingEntries.Take(20)) |
| | | 319 | | { |
| | | 320 | | _console.MarkupLine( |
| | | 321 | | "[red] Missing prediction:[/] " + |
| | | 322 | | $"{entry.DocumentName} | {entry.Competition} | {entry.HomeTeam} vs {entry.AwayTeam} | " + |
| | | 323 | | $"{entry.Score} | {entry.Annotation} | collected {entry.PlayedAt}"); |
| | | 324 | | } |
| | | 325 | | |
| | | 326 | | if (missingEntries.Count > 20) |
| | | 327 | | { |
| | | 328 | | _console.MarkupLine($"[dim] ... and {missingEntries.Count - 20} more[/]"); |
| | | 329 | | } |
| | | 330 | | } |
| | | 331 | | |
| | | 332 | | private static bool IsRecentHistoryDocument(string documentName) |
| | | 333 | | { |
| | | 334 | | return documentName.StartsWith("recent-history-", StringComparison.OrdinalIgnoreCase) |
| | | 335 | | && documentName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase); |
| | | 336 | | } |
| | | 337 | | |
| | 1 | 338 | | private sealed record PlannedUpdate( |
| | 1 | 339 | | string DocumentName, |
| | 1 | 340 | | ContextDocument Document, |
| | 1 | 341 | | HistoryDateMapApplyResult Result); |
| | | 342 | | |
| | | 343 | | private sealed record PredictionLookupKey(string HomeTeam, string AwayTeam); |
| | | 344 | | } |