< Summary

Information
Class: FirebaseAdapter.FirebasePredictionRepository
Assembly: FirebaseAdapter
File(s): /home/runner/work/KicktippAi/KicktippAi/src/FirebaseAdapter/FirebasePredictionRepository.cs
Line coverage
86%
Covered lines: 847
Uncovered lines: 131
Coverable lines: 978
Total lines: 1808
Line coverage: 86.6%
Branch coverage
85%
Covered branches: 312
Total branches: 366
Branch coverage: 85.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%66100%
GetConfigMatchKind(...)100%11100%
GetConfigMatchKind(...)100%11100%
GetConfigMatchKind(...)60%181057.14%
SelectLatestForModelConfig(...)100%1212100%
SelectLatestForModelConfig(...)100%1212100%
SavePredictionAsync(...)100%11100%
SavePredictionAsync()100%8893.33%
GetPredictionAsync(...)100%11100%
GetPredictionAsync()100%11100%
GetPredictionAsync(...)100%210%
GetPredictionAsync()100%4481.82%
GetLatestPredictedMatchByTeamsAsync()100%1160%
GetPredictionMetadataAsync(...)100%11100%
GetPredictionMetadataAsync()100%2280%
GetMatchDayAsync()100%4475%
GetStoredMatchAsync(...)100%22100%
GetStoredMatchAsync()94.74%413887.5%
GetMatchDayWithPredictionsAsync(...)100%11100%
GetMatchDayWithPredictionsAsync()100%2272.73%
GetAllPredictionsAsync(...)100%11100%
GetAllPredictionsAsync()100%2276.92%
HasPredictionAsync(...)100%11100%
HasPredictionAsync()100%2271.43%
SaveBonusPredictionAsync(...)100%11100%
SaveBonusPredictionAsync()100%121292.98%
GetBonusPredictionAsync(...)100%11100%
GetBonusPredictionAsync()83.33%6676.47%
GetBonusPredictionByTextAsync(...)100%11100%
GetBonusPredictionByTextAsync()100%4485%
GetBonusPredictionMetadataByTextAsync(...)100%11100%
GetBonusPredictionMetadataByTextAsync()62.5%9877.27%
GetAllBonusPredictionsAsync(...)100%11100%
GetAllBonusPredictionsAsync()100%4482.35%
HasBonusPredictionAsync(...)100%11100%
HasBonusPredictionAsync()100%2272.73%
StoreMatchAsync()100%2281.82%
ConvertToTimestamp(...)100%11100%
ConvertFromTimestamp(...)100%11100%
GetMatchRepredictionIndexAsync(...)100%11100%
GetMatchRepredictionIndexAsync()100%4478.95%
GetCancelledMatchPredictionAsync(...)100%11100%
GetCancelledMatchPredictionAsync()100%4483.33%
GetCancelledMatchPredictionMetadataAsync(...)100%11100%
GetCancelledMatchPredictionMetadataAsync()75%8885.19%
GetCancelledMatchRepredictionIndexAsync(...)100%11100%
GetCancelledMatchRepredictionIndexAsync()100%4480.95%
GetBonusRepredictionIndexAsync(...)100%11100%
GetBonusRepredictionIndexAsync()100%4476.47%
SaveRepredictionAsync(...)100%11100%
SaveRepredictionAsync()100%1188.89%
SaveBonusRepredictionAsync(...)100%11100%
SaveBonusRepredictionAsync()100%4488.57%
GetMatchPredictionCostsByRepredictionIndexAsync(...)100%11100%
GetMatchPredictionCostsByRepredictionIndexAsync()100%131282.61%
GetBonusPredictionCostsByRepredictionIndexAsync(...)100%11100%
GetBonusPredictionCostsByRepredictionIndexAsync()100%8880.95%
GetAvailableMatchdaysAsync()100%9875%
GetAvailableModelsAsync()100%151483.33%
GetAvailableModelConfigsAsync()100%8882.35%
AddModelConfigIfValid(...)100%11100%
AddModelConfigIfValid(...)100%11100%
AddModelConfigIfValid(...)50%4471.43%
GetAvailableCommunityContextsAsync()100%151483.33%
SerializeJustification(...)69.44%3636100%
HasJustificationContent(...)81.82%2222100%
HasSourceContent(...)33.33%66100%
DeserializeJustification(...)67.5%404097.3%
ToStoredContextSource(...)100%88100%
ToDomainContextSource(...)50%88100%

File(s)

/home/runner/work/KicktippAi/KicktippAi/src/FirebaseAdapter/FirebasePredictionRepository.cs

#LineLine coverage
 1using System.Collections.Generic;
 2using System.Linq;
 3using System.Text.Json;
 4using System.Text.Json.Serialization;
 5using EHonda.KicktippAi.Core;
 6using FirebaseAdapter.Models;
 7using Google.Cloud.Firestore;
 8using Microsoft.Extensions.Logging;
 9using NodaTime;
 10
 11namespace FirebaseAdapter;
 12
 13/// <summary>
 14/// Firebase Firestore implementation of the prediction repository.
 15/// </summary>
 16public class FirebasePredictionRepository : IPredictionRepository
 17{
 118    private static readonly JsonSerializerOptions JustificationSerializerOptions = new()
 119    {
 120        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
 121        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
 122    };
 23
 24    private readonly FirestoreDb _firestoreDb;
 25    private readonly ILogger<FirebasePredictionRepository> _logger;
 26    private readonly string _predictionsCollection;
 27    private readonly string _matchesCollection;
 28    private readonly string _bonusPredictionsCollection;
 29    private readonly string _competition;
 30
 31    private enum PredictionConfigMatchKind
 32    {
 33        None = 0,
 34        LegacyModelOnly = 1,
 35        Exact = 2
 36    }
 37
 138    public FirebasePredictionRepository(
 139        FirestoreDb firestoreDb,
 140        ILogger<FirebasePredictionRepository> logger,
 141        string? competition = null)
 42    {
 143        _firestoreDb = firestoreDb ?? throw new ArgumentNullException(nameof(firestoreDb));
 144        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
 45
 46        // Use unified collection names (no longer community-specific)
 147        _predictionsCollection = "match-predictions";
 148        _matchesCollection = "matches";
 149        _bonusPredictionsCollection = "bonus-predictions";
 150        _competition = string.IsNullOrWhiteSpace(competition)
 151            ? CompetitionIds.Bundesliga2025_26
 152            : competition.Trim();
 53
 154        _logger.LogInformation("Firebase repository initialized");
 155    }
 56
 57    private static PredictionConfigMatchKind GetConfigMatchKind(FirestoreMatchPrediction prediction, PredictionModelConf
 58    {
 159        return GetConfigMatchKind(prediction.ModelConfigKey, prediction.ReasoningEffort, modelConfig);
 60    }
 61
 62    private static PredictionConfigMatchKind GetConfigMatchKind(FirestoreBonusPrediction prediction, PredictionModelConf
 63    {
 164        return GetConfigMatchKind(prediction.ModelConfigKey, prediction.ReasoningEffort, modelConfig);
 65    }
 66
 67    private static PredictionConfigMatchKind GetConfigMatchKind(
 68        string? storedModelConfigKey,
 69        string? storedReasoningEffort,
 70        PredictionModelConfig modelConfig)
 71    {
 172        if (!string.IsNullOrWhiteSpace(storedModelConfigKey))
 73        {
 174            return string.Equals(storedModelConfigKey.Trim(), modelConfig.IdentityKey, StringComparison.Ordinal)
 175                ? PredictionConfigMatchKind.Exact
 176                : PredictionConfigMatchKind.None;
 77        }
 78
 179        if (!string.IsNullOrWhiteSpace(storedReasoningEffort))
 80        {
 081            if (!PredictionModelConfig.IsValidReasoningEffort(storedReasoningEffort))
 82            {
 083                return PredictionConfigMatchKind.None;
 84            }
 85
 086            var normalizedReasoningEffort = PredictionModelConfig.NormalizeReasoningEffort(storedReasoningEffort);
 087            return string.Equals(normalizedReasoningEffort, modelConfig.ReasoningEffort, StringComparison.Ordinal)
 088                ? PredictionConfigMatchKind.Exact
 089                : PredictionConfigMatchKind.None;
 90        }
 91
 192        return modelConfig.AllowsLegacyModelOnlyLookup
 193            ? PredictionConfigMatchKind.LegacyModelOnly
 194            : PredictionConfigMatchKind.None;
 95    }
 96
 97    private static FirestoreMatchPrediction? SelectLatestForModelConfig(
 98        IEnumerable<FirestoreMatchPrediction> predictions,
 99        PredictionModelConfig modelConfig)
 100    {
 1101        return predictions
 1102            .Select(prediction => new
 1103            {
 1104                Prediction = prediction,
 1105                MatchKind = GetConfigMatchKind(prediction, modelConfig)
 1106            })
 1107            .Where(candidate => candidate.MatchKind != PredictionConfigMatchKind.None)
 1108            .OrderByDescending(candidate => candidate.MatchKind)
 1109            .ThenByDescending(candidate => candidate.Prediction.RepredictionIndex)
 1110            .ThenByDescending(candidate => candidate.Prediction.CreatedAt.ToDateTimeOffset())
 1111            .ThenBy(candidate => candidate.Prediction.Id, StringComparer.Ordinal)
 1112            .Select(candidate => candidate.Prediction)
 1113            .FirstOrDefault();
 114    }
 115
 116    private static FirestoreBonusPrediction? SelectLatestForModelConfig(
 117        IEnumerable<FirestoreBonusPrediction> predictions,
 118        PredictionModelConfig modelConfig)
 119    {
 1120        return predictions
 1121            .Select(prediction => new
 1122            {
 1123                Prediction = prediction,
 1124                MatchKind = GetConfigMatchKind(prediction, modelConfig)
 1125            })
 1126            .Where(candidate => candidate.MatchKind != PredictionConfigMatchKind.None)
 1127            .OrderByDescending(candidate => candidate.MatchKind)
 1128            .ThenByDescending(candidate => candidate.Prediction.RepredictionIndex)
 1129            .ThenByDescending(candidate => candidate.Prediction.CreatedAt.ToDateTimeOffset())
 1130            .ThenBy(candidate => candidate.Prediction.Id, StringComparer.Ordinal)
 1131            .Select(candidate => candidate.Prediction)
 1132            .FirstOrDefault();
 133    }
 134
 135    public Task SavePredictionAsync(Match match, Prediction prediction, string model, string tokenUsage, double cost, st
 136    {
 1137        return SavePredictionAsync(
 1138            match,
 1139            prediction,
 1140            PredictionModelConfig.Create(model),
 1141            tokenUsage,
 1142            cost,
 1143            communityContext,
 1144            contextDocumentNames,
 1145            overrideCreatedAt,
 1146            cancellationToken);
 147    }
 148
 149    public async Task SavePredictionAsync(Match match, Prediction prediction, PredictionModelConfig modelConfig, string 
 150    {
 151        try
 152        {
 1153            var now = Timestamp.GetCurrentTimestamp();
 154
 155            // Check if a prediction already exists for this match, model, and community context
 156            // Order by repredictionIndex descending to get the latest version for updating
 1157            var query = _firestoreDb.Collection(_predictionsCollection)
 1158                .WhereEqualTo("homeTeam", match.HomeTeam)
 1159                .WhereEqualTo("awayTeam", match.AwayTeam)
 1160                .WhereEqualTo("startsAt", ConvertToTimestamp(match.StartsAt))
 1161                .WhereEqualTo("competition", _competition)
 1162                .WhereEqualTo("model", modelConfig.Model)
 1163                .WhereEqualTo("communityContext", communityContext)
 1164                .OrderByDescending("repredictionIndex");
 165
 1166            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 167
 168            DocumentReference docRef;
 1169            bool isUpdate = false;
 1170            Timestamp? existingCreatedAt = null;
 1171            int repredictionIndex = 0;
 172
 1173            var existingDoc = snapshot.Documents
 1174                .FirstOrDefault(document =>
 1175                    GetConfigMatchKind(document.ConvertTo<FirestoreMatchPrediction>(), modelConfig) == PredictionConfigM
 176
 1177            if (existingDoc is not null)
 178            {
 179                // Update existing document (latest reprediction)
 1180                docRef = existingDoc.Reference;
 1181                isUpdate = true;
 182
 183                // Preserve the original values
 1184                var existingData = existingDoc.ConvertTo<FirestoreMatchPrediction>();
 1185                existingCreatedAt = existingData.CreatedAt;
 1186                repredictionIndex = existingData.RepredictionIndex; // Keep same reprediction index for override
 187
 1188                _logger.LogDebug("Updating existing prediction for match {HomeTeam} vs {AwayTeam} (document: {DocumentId
 1189                    match.HomeTeam, match.AwayTeam, existingDoc.Id, repredictionIndex);
 190            }
 191            else
 192            {
 193                // Create new document
 1194                var documentId = Guid.NewGuid().ToString();
 1195                docRef = _firestoreDb.Collection(_predictionsCollection).Document(documentId);
 1196                repredictionIndex = 0; // First prediction
 197
 1198                _logger.LogDebug("Creating new prediction for match {HomeTeam} vs {AwayTeam} (document: {DocumentId}, re
 1199                    match.HomeTeam, match.AwayTeam, documentId, repredictionIndex);
 200            }
 201
 1202            var firestorePrediction = new FirestoreMatchPrediction
 1203            {
 1204                Id = docRef.Id,
 1205                HomeTeam = match.HomeTeam,
 1206                AwayTeam = match.AwayTeam,
 1207                StartsAt = ConvertToTimestamp(match.StartsAt),
 1208                Matchday = match.Matchday,
 1209                HomeGoals = prediction.HomeGoals,
 1210                AwayGoals = prediction.AwayGoals,
 1211                Justification = SerializeJustification(prediction.Justification),
 1212                UpdatedAt = now,
 1213                Competition = _competition,
 1214                Model = modelConfig.Model,
 1215                ModelConfigKey = modelConfig.IdentityKey,
 1216                ReasoningEffort = modelConfig.ReasoningEffort,
 1217                TokenUsage = tokenUsage,
 1218                Cost = cost,
 1219                CommunityContext = communityContext,
 1220                ContextDocumentNames = contextDocumentNames.ToArray(),
 1221                RepredictionIndex = repredictionIndex
 1222            };
 223
 224            // Set CreatedAt: preserve existing value for updates unless overrideCreatedAt is explicitly requested
 1225            firestorePrediction.CreatedAt = (overrideCreatedAt || existingCreatedAt == null) ? now : existingCreatedAt.V
 226
 1227            await docRef.SetAsync(firestorePrediction, cancellationToken: cancellationToken);
 228
 1229            var action = isUpdate ? "Updated" : "Saved";
 1230            _logger.LogInformation("{Action} prediction for match {HomeTeam} vs {AwayTeam} on matchday {Matchday} (repre
 1231                action, match.HomeTeam, match.AwayTeam, match.Matchday, repredictionIndex);
 1232        }
 0233        catch (Exception ex)
 234        {
 0235            _logger.LogError(ex, "Failed to save prediction for match {HomeTeam} vs {AwayTeam}",
 0236                match.HomeTeam, match.AwayTeam);
 0237            throw;
 238        }
 1239    }
 240
 241    public Task<Prediction?> GetPredictionAsync(Match match, string model, string communityContext, CancellationToken ca
 242    {
 1243        return GetPredictionAsync(match, PredictionModelConfig.Create(model), communityContext, cancellationToken);
 244    }
 245
 246    public async Task<Prediction?> GetPredictionAsync(Match match, PredictionModelConfig modelConfig, string communityCo
 247    {
 1248        return await GetPredictionAsync(match.HomeTeam, match.AwayTeam, match.StartsAt, modelConfig, communityContext, c
 1249    }
 250
 251    public Task<Prediction?> GetPredictionAsync(string homeTeam, string awayTeam, ZonedDateTime startsAt, string model, 
 252    {
 0253        return GetPredictionAsync(homeTeam, awayTeam, startsAt, PredictionModelConfig.Create(model), communityContext, c
 254    }
 255
 256    public async Task<Prediction?> GetPredictionAsync(string homeTeam, string awayTeam, ZonedDateTime startsAt, Predicti
 257    {
 258        try
 259        {
 260            // Query by match characteristics, model, community context, and competition
 261            // Order by repredictionIndex descending to get the latest version
 1262            var query = _firestoreDb.Collection(_predictionsCollection)
 1263                .WhereEqualTo("homeTeam", homeTeam)
 1264                .WhereEqualTo("awayTeam", awayTeam)
 1265                .WhereEqualTo("startsAt", ConvertToTimestamp(startsAt))
 1266                .WhereEqualTo("competition", _competition)
 1267                .WhereEqualTo("model", modelConfig.Model)
 1268                .WhereEqualTo("communityContext", communityContext)
 1269                .OrderByDescending("repredictionIndex");
 270
 1271            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 1272            var firestorePrediction = SelectLatestForModelConfig(
 1273                snapshot.Documents.Select(document => document.ConvertTo<FirestoreMatchPrediction>()),
 1274                modelConfig);
 275
 1276            if (firestorePrediction is null)
 277            {
 1278                return null;
 279            }
 280
 1281            return new Prediction(
 1282                firestorePrediction.HomeGoals,
 1283                firestorePrediction.AwayGoals,
 1284                DeserializeJustification(firestorePrediction.Justification));
 285        }
 0286        catch (Exception ex)
 287        {
 0288            _logger.LogError(ex, "Failed to get prediction for match {HomeTeam} vs {AwayTeam} using model {Model} and co
 0289                homeTeam, awayTeam, modelConfig.DisplayName, communityContext);
 0290            throw;
 291        }
 1292    }
 293
 294    public async Task<Match?> GetLatestPredictedMatchByTeamsAsync(
 295        string homeTeam,
 296        string awayTeam,
 297        string communityContext,
 298        CancellationToken cancellationToken = default)
 299    {
 300        try
 301        {
 1302            var normalizedHomeTeam = homeTeam.Trim();
 1303            var normalizedAwayTeam = awayTeam.Trim();
 1304            var normalizedCommunityContext = communityContext.Trim();
 305
 1306            var query = _firestoreDb.Collection(_predictionsCollection)
 1307                .WhereEqualTo("competition", _competition)
 1308                .WhereEqualTo("communityContext", normalizedCommunityContext)
 1309                .WhereEqualTo("homeTeam", normalizedHomeTeam)
 1310                .WhereEqualTo("awayTeam", normalizedAwayTeam)
 1311                .OrderByDescending("startsAt")
 1312                .Limit(1);
 313
 1314            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 1315            var firestorePrediction = snapshot.Documents
 1316                .FirstOrDefault()
 1317                ?.ConvertTo<FirestoreMatchPrediction>();
 318
 1319            if (firestorePrediction is null)
 320            {
 0321                _logger.LogDebug(
 0322                    "No predicted match found for {HomeTeam} vs {AwayTeam} in community context {CommunityContext}",
 0323                    normalizedHomeTeam,
 0324                    normalizedAwayTeam,
 0325                    normalizedCommunityContext);
 0326                return null;
 327            }
 328
 1329            return new Match(
 1330                firestorePrediction.HomeTeam,
 1331                firestorePrediction.AwayTeam,
 1332                ConvertFromTimestamp(firestorePrediction.StartsAt),
 1333                firestorePrediction.Matchday);
 334        }
 0335        catch (Exception ex)
 336        {
 0337            _logger.LogError(
 0338                ex,
 0339                "Failed to get latest predicted match for {HomeTeam} vs {AwayTeam} in community context {CommunityContex
 0340                homeTeam,
 0341                awayTeam,
 0342                communityContext);
 0343            throw;
 344        }
 1345    }
 346
 347    public Task<PredictionMetadata?> GetPredictionMetadataAsync(Match match, string model, string communityContext, Canc
 348    {
 1349        return GetPredictionMetadataAsync(match, PredictionModelConfig.Create(model), communityContext, cancellationToke
 350    }
 351
 352    public async Task<PredictionMetadata?> GetPredictionMetadataAsync(Match match, PredictionModelConfig modelConfig, st
 353    {
 354        try
 355        {
 356            // Query by match characteristics, model, community context, and competition.
 357            // Order by repredictionIndex descending to keep metadata reads aligned with latest prediction retrieval.
 1358            var query = _firestoreDb.Collection(_predictionsCollection)
 1359                .WhereEqualTo("homeTeam", match.HomeTeam)
 1360                .WhereEqualTo("awayTeam", match.AwayTeam)
 1361                .WhereEqualTo("startsAt", ConvertToTimestamp(match.StartsAt))
 1362                .WhereEqualTo("competition", _competition)
 1363                .WhereEqualTo("model", modelConfig.Model)
 1364                .WhereEqualTo("communityContext", communityContext)
 1365                .OrderByDescending("repredictionIndex");
 366
 1367            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 1368            var firestorePrediction = SelectLatestForModelConfig(
 1369                snapshot.Documents.Select(document => document.ConvertTo<FirestoreMatchPrediction>()),
 1370                modelConfig);
 371
 1372            if (firestorePrediction is null)
 373            {
 0374                return null;
 375            }
 376
 1377            var prediction = new Prediction(
 1378                firestorePrediction.HomeGoals,
 1379                firestorePrediction.AwayGoals,
 1380                DeserializeJustification(firestorePrediction.Justification));
 1381            var createdAt = firestorePrediction.CreatedAt.ToDateTimeOffset();
 1382            var contextDocumentNames = firestorePrediction.ContextDocumentNames?.ToList() ?? new List<string>();
 383
 1384            return new PredictionMetadata(prediction, createdAt, contextDocumentNames);
 385        }
 0386        catch (Exception ex)
 387        {
 0388            _logger.LogError(ex, "Failed to get prediction metadata for match {HomeTeam} vs {AwayTeam} using model {Mode
 0389                match.HomeTeam, match.AwayTeam, modelConfig.DisplayName, communityContext);
 0390            throw;
 391        }
 1392    }
 393
 394    public async Task<IReadOnlyList<Match>> GetMatchDayAsync(int matchDay, CancellationToken cancellationToken = default
 395    {
 396        try
 397        {
 1398            var query = _firestoreDb.Collection(_matchesCollection)
 1399                .WhereEqualTo("competition", _competition)
 1400                .WhereEqualTo("matchday", matchDay)
 1401                .OrderBy("startsAt");
 402
 1403            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 404
 1405            var matches = snapshot.Documents
 1406                .Select(doc => doc.ConvertTo<FirestoreMatch>())
 1407                .Select(fm => new Match(
 1408                    fm.HomeTeam,
 1409                    fm.AwayTeam,
 1410                    ConvertFromTimestamp(fm.StartsAt),
 1411                    fm.Matchday,
 1412                    fm.IsCancelled))
 1413                .ToList();
 414
 1415            return matches.AsReadOnly();
 416        }
 0417        catch (Exception ex)
 418        {
 0419            _logger.LogError(ex, "Failed to get matches for matchday {Matchday}", matchDay);
 0420            throw;
 421        }
 1422    }
 423
 424    public Task<Match?> GetStoredMatchAsync(string homeTeam, string awayTeam, int matchDay, string? model = null, string
 425    {
 1426        var modelConfig = string.IsNullOrWhiteSpace(model)
 1427            ? null
 1428            : PredictionModelConfig.Create(model);
 1429        return GetStoredMatchAsync(homeTeam, awayTeam, matchDay, modelConfig, communityContext, cancellationToken);
 430    }
 431
 432    public async Task<Match?> GetStoredMatchAsync(string homeTeam, string awayTeam, int matchDay, PredictionModelConfig?
 433    {
 434        try
 435        {
 1436            var matchQuery = _firestoreDb.Collection(_matchesCollection)
 1437                .WhereEqualTo("competition", _competition)
 1438                .WhereEqualTo("matchday", matchDay)
 1439                .WhereEqualTo("homeTeam", homeTeam)
 1440                .WhereEqualTo("awayTeam", awayTeam);
 441
 1442            var matchSnapshot = await matchQuery.GetSnapshotAsync(cancellationToken);
 443
 1444            if (matchSnapshot.Documents.Count > 0)
 445            {
 1446                if (matchSnapshot.Documents.Count > 1)
 447                {
 1448                    _logger.LogWarning("Found {Count} stored match documents for {HomeTeam} vs {AwayTeam} on matchday {M
 449                }
 450
 1451                return matchSnapshot.Documents
 1452                    .Select(document => document.ConvertTo<FirestoreMatch>())
 1453                    .Select(firestoreMatch => new Match(
 1454                        firestoreMatch.HomeTeam,
 1455                        firestoreMatch.AwayTeam,
 1456                        ConvertFromTimestamp(firestoreMatch.StartsAt),
 1457                        firestoreMatch.Matchday,
 1458                        firestoreMatch.IsCancelled))
 1459                    .OrderBy(match => match.StartsAt.ToInstant())
 1460                    .ThenBy(match => match.IsCancelled)
 1461                    .First();
 462            }
 463
 1464            Query predictionQuery = _firestoreDb.Collection(_predictionsCollection)
 1465                .WhereEqualTo("competition", _competition)
 1466                .WhereEqualTo("matchday", matchDay)
 1467                .WhereEqualTo("homeTeam", homeTeam)
 1468                .WhereEqualTo("awayTeam", awayTeam);
 469
 1470            if (modelConfig is not null)
 471            {
 1472                predictionQuery = predictionQuery.WhereEqualTo("model", modelConfig.Model);
 473            }
 474
 1475            if (!string.IsNullOrWhiteSpace(communityContext))
 476            {
 1477                predictionQuery = predictionQuery.WhereEqualTo("communityContext", communityContext);
 478            }
 479
 1480            var predictionSnapshot = await predictionQuery.GetSnapshotAsync(cancellationToken);
 481
 1482            if (predictionSnapshot.Documents.Count == 0)
 483            {
 0484                return null;
 485            }
 486
 1487            if (predictionSnapshot.Documents.Count > 1)
 488            {
 1489                _logger.LogWarning("Found {Count} stored prediction documents for {HomeTeam} vs {AwayTeam} on matchday {
 490            }
 491
 1492            var predictions = predictionSnapshot.Documents
 1493                .Select(document => document.ConvertTo<FirestoreMatchPrediction>())
 1494                .Select(prediction => new
 1495                {
 1496                    Prediction = prediction,
 1497                    MatchKind = modelConfig is null
 1498                        ? PredictionConfigMatchKind.Exact
 1499                        : GetConfigMatchKind(prediction, modelConfig)
 1500                })
 1501                .Where(candidate => candidate.MatchKind != PredictionConfigMatchKind.None)
 1502                .ToList();
 503
 1504            if (predictions.Count == 0)
 505            {
 0506                return null;
 507            }
 508
 1509            var firestorePrediction = predictions
 1510                .OrderByDescending(candidate => candidate.MatchKind)
 1511                .ThenByDescending(candidate => candidate.Prediction.RepredictionIndex)
 1512                .ThenByDescending(candidate => candidate.Prediction.CreatedAt.ToDateTimeOffset())
 1513                .ThenBy(candidate => candidate.Prediction.StartsAt.ToDateTimeOffset())
 1514                .ThenBy(candidate => candidate.Prediction.Id, StringComparer.Ordinal)
 1515                .Select(candidate => candidate.Prediction)
 1516                .First();
 517
 1518            return new Match(
 1519                firestorePrediction.HomeTeam,
 1520                firestorePrediction.AwayTeam,
 1521                ConvertFromTimestamp(firestorePrediction.StartsAt),
 1522                firestorePrediction.Matchday);
 523        }
 0524        catch (Exception ex)
 525        {
 0526            _logger.LogError(ex, "Failed to get stored match {HomeTeam} vs {AwayTeam} for matchday {Matchday}", homeTeam
 0527            throw;
 528        }
 1529    }
 530
 531    public Task<IReadOnlyList<MatchPrediction>> GetMatchDayWithPredictionsAsync(int matchDay, string model, string commu
 532    {
 1533        return GetMatchDayWithPredictionsAsync(matchDay, PredictionModelConfig.Create(model), communityContext, cancella
 534    }
 535
 536    public async Task<IReadOnlyList<MatchPrediction>> GetMatchDayWithPredictionsAsync(int matchDay, PredictionModelConfi
 537    {
 538        try
 539        {
 540            // Get all matches for the matchday
 1541            var matches = await GetMatchDayAsync(matchDay, cancellationToken);
 542
 543            // Get predictions for all matches using the specified model and community context
 1544            var matchPredictions = new List<MatchPrediction>();
 545
 1546            foreach (var match in matches)
 547            {
 1548                var prediction = await GetPredictionAsync(match, modelConfig, communityContext, cancellationToken);
 1549                matchPredictions.Add(new MatchPrediction(match, prediction));
 1550            }
 551
 1552            return matchPredictions.AsReadOnly();
 553        }
 0554        catch (Exception ex)
 555        {
 0556            _logger.LogError(ex, "Failed to get matches with predictions for matchday {Matchday} using model {Model} and
 0557            throw;
 558        }
 1559    }
 560
 561    public Task<IReadOnlyList<MatchPrediction>> GetAllPredictionsAsync(string model, string communityContext, Cancellati
 562    {
 1563        return GetAllPredictionsAsync(PredictionModelConfig.Create(model), communityContext, cancellationToken);
 564    }
 565
 566    public async Task<IReadOnlyList<MatchPrediction>> GetAllPredictionsAsync(PredictionModelConfig modelConfig, string c
 567    {
 568        try
 569        {
 1570            var query = _firestoreDb.Collection(_predictionsCollection)
 1571                .WhereEqualTo("competition", _competition)
 1572                .WhereEqualTo("model", modelConfig.Model)
 1573                .WhereEqualTo("communityContext", communityContext)
 1574                .OrderBy("matchday");
 575
 1576            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 577
 1578            var matchPredictions = snapshot.Documents
 1579                .Select(doc => doc.ConvertTo<FirestoreMatchPrediction>())
 1580                .Where(fp => GetConfigMatchKind(fp, modelConfig) != PredictionConfigMatchKind.None)
 1581                .Select(fp => new MatchPrediction(
 1582                    new Match(fp.HomeTeam, fp.AwayTeam, ConvertFromTimestamp(fp.StartsAt), fp.Matchday),
 1583                    new Prediction(
 1584                        fp.HomeGoals,
 1585                        fp.AwayGoals,
 1586                        DeserializeJustification(fp.Justification))))
 1587                .ToList();
 588
 1589            return matchPredictions.AsReadOnly();
 590        }
 0591        catch (Exception ex)
 592        {
 0593            _logger.LogError(ex, "Failed to get all predictions for model {Model} and community context {CommunityContex
 0594            throw;
 595        }
 1596    }
 597
 598    public Task<bool> HasPredictionAsync(Match match, string model, string communityContext, CancellationToken cancellat
 599    {
 1600        return HasPredictionAsync(match, PredictionModelConfig.Create(model), communityContext, cancellationToken);
 601    }
 602
 603    public async Task<bool> HasPredictionAsync(Match match, PredictionModelConfig modelConfig, string communityContext, 
 604    {
 605        try
 606        {
 607            // Query by match characteristics, model, and community context instead of using deterministic ID
 1608            var query = _firestoreDb.Collection(_predictionsCollection)
 1609                .WhereEqualTo("homeTeam", match.HomeTeam)
 1610                .WhereEqualTo("awayTeam", match.AwayTeam)
 1611                .WhereEqualTo("startsAt", ConvertToTimestamp(match.StartsAt))
 1612                .WhereEqualTo("competition", _competition)
 1613                .WhereEqualTo("model", modelConfig.Model)
 1614                .WhereEqualTo("communityContext", communityContext);
 615
 1616            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 1617            return snapshot.Documents
 1618                .Select(document => document.ConvertTo<FirestoreMatchPrediction>())
 1619                .Any(prediction => GetConfigMatchKind(prediction, modelConfig) != PredictionConfigMatchKind.None);
 620        }
 0621        catch (Exception ex)
 622        {
 0623            _logger.LogError(ex, "Failed to check if prediction exists for match {HomeTeam} vs {AwayTeam} using model {M
 0624                match.HomeTeam, match.AwayTeam, modelConfig.DisplayName, communityContext);
 0625            throw;
 626        }
 1627    }
 628
 629    public Task SaveBonusPredictionAsync(BonusQuestion bonusQuestion, BonusPrediction bonusPrediction, string model, str
 630    {
 1631        return SaveBonusPredictionAsync(
 1632            bonusQuestion,
 1633            bonusPrediction,
 1634            PredictionModelConfig.Create(model),
 1635            tokenUsage,
 1636            cost,
 1637            communityContext,
 1638            contextDocumentNames,
 1639            overrideCreatedAt,
 1640            cancellationToken);
 641    }
 642
 643    public async Task SaveBonusPredictionAsync(BonusQuestion bonusQuestion, BonusPrediction bonusPrediction, PredictionM
 644    {
 645        try
 646        {
 1647            var now = Timestamp.GetCurrentTimestamp();
 648
 649            // Check if a prediction already exists for this question, model, and community context
 650            // Order by repredictionIndex descending to get the latest version for updating
 1651            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 1652                .WhereEqualTo("questionText", bonusQuestion.Text)
 1653                .WhereEqualTo("competition", _competition)
 1654                .WhereEqualTo("model", modelConfig.Model)
 1655                .WhereEqualTo("communityContext", communityContext)
 1656                .OrderByDescending("repredictionIndex");
 657
 1658            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 659
 660            DocumentReference docRef;
 1661            bool isUpdate = false;
 1662            Timestamp? existingCreatedAt = null;
 1663            int repredictionIndex = 0;
 664
 1665            var existingDoc = snapshot.Documents
 1666                .FirstOrDefault(document =>
 1667                    GetConfigMatchKind(document.ConvertTo<FirestoreBonusPrediction>(), modelConfig) == PredictionConfigM
 668
 1669            if (existingDoc is not null)
 670            {
 671                // Update existing document (latest reprediction)
 1672                docRef = existingDoc.Reference;
 1673                isUpdate = true;
 674
 675                // Preserve the original values
 1676                var existingData = existingDoc.ConvertTo<FirestoreBonusPrediction>();
 1677                existingCreatedAt = existingData.CreatedAt;
 1678                repredictionIndex = existingData.RepredictionIndex; // Keep same reprediction index for override
 679
 1680                _logger.LogDebug("Updating existing bonus prediction for question '{QuestionText}' (document: {DocumentI
 1681                    bonusQuestion.Text, existingDoc.Id, repredictionIndex);
 682            }
 683            else
 684            {
 685                // Create new document
 1686                var documentId = Guid.NewGuid().ToString();
 1687                docRef = _firestoreDb.Collection(_bonusPredictionsCollection).Document(documentId);
 1688                repredictionIndex = 0; // First prediction
 689
 1690                _logger.LogDebug("Creating new bonus prediction for question '{QuestionText}' (document: {DocumentId}, r
 1691                    bonusQuestion.Text, documentId, repredictionIndex);
 692            }
 693
 694            // Extract selected option texts for observability
 1695            var optionTextsLookup = bonusQuestion.Options.ToDictionary(o => o.Id, o => o.Text);
 1696            var selectedOptionTexts = bonusPrediction.SelectedOptionIds
 1697                .Select(id => optionTextsLookup.TryGetValue(id, out var text) ? text : $"Unknown option: {id}")
 1698                .ToArray();
 699
 1700            var firestoreBonusPrediction = new FirestoreBonusPrediction
 1701            {
 1702                Id = docRef.Id,
 1703                QuestionText = bonusQuestion.Text,
 1704                SelectedOptionIds = bonusPrediction.SelectedOptionIds.ToArray(),
 1705                SelectedOptionTexts = selectedOptionTexts,
 1706                UpdatedAt = now,
 1707                Competition = _competition,
 1708                Model = modelConfig.Model,
 1709                ModelConfigKey = modelConfig.IdentityKey,
 1710                ReasoningEffort = modelConfig.ReasoningEffort,
 1711                TokenUsage = tokenUsage,
 1712                Cost = cost,
 1713                CommunityContext = communityContext,
 1714                ContextDocumentNames = contextDocumentNames.ToArray(),
 1715                RepredictionIndex = repredictionIndex
 1716            };
 717
 718            // Set CreatedAt: preserve existing value for updates unless overrideCreatedAt is explicitly requested
 1719            firestoreBonusPrediction.CreatedAt = (overrideCreatedAt || existingCreatedAt == null) ? now : existingCreate
 720
 1721            await docRef.SetAsync(firestoreBonusPrediction, cancellationToken: cancellationToken);
 722
 1723            var action = isUpdate ? "Updated" : "Saved";
 1724            _logger.LogDebug("{Action} bonus prediction for question '{QuestionText}' with selections: {SelectedOptions}
 1725                action, bonusQuestion.Text, string.Join(", ", selectedOptionTexts), repredictionIndex);
 1726        }
 0727        catch (Exception ex)
 728        {
 0729            _logger.LogError(ex, "Failed to save bonus prediction for question: {QuestionText}",
 0730                bonusQuestion.Text);
 0731            throw;
 732        }
 1733    }
 734
 735    public Task<BonusPrediction?> GetBonusPredictionAsync(string questionId, string model, string communityContext, Canc
 736    {
 1737        return GetBonusPredictionAsync(questionId, PredictionModelConfig.Create(model), communityContext, cancellationTo
 738    }
 739
 740    public async Task<BonusPrediction?> GetBonusPredictionAsync(string questionId, PredictionModelConfig modelConfig, st
 741    {
 742        try
 743        {
 744            // Query by questionId, model, community context, and competition instead of using direct document lookup
 1745            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 1746                .WhereEqualTo("questionId", questionId)
 1747                .WhereEqualTo("competition", _competition)
 1748                .WhereEqualTo("model", modelConfig.Model)
 1749                .WhereEqualTo("communityContext", communityContext);
 750
 1751            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 752
 1753            if (snapshot.Documents.Count == 0)
 754            {
 1755                return null;
 756            }
 757
 1758            var firestoreBonusPrediction = SelectLatestForModelConfig(
 1759                snapshot.Documents.Select(document => document.ConvertTo<FirestoreBonusPrediction>()),
 1760                modelConfig);
 761
 1762            if (firestoreBonusPrediction is null)
 763            {
 0764                return null;
 765            }
 766
 1767            return new BonusPrediction(firestoreBonusPrediction.SelectedOptionIds.ToList());
 768        }
 0769        catch (Exception ex)
 770        {
 0771            _logger.LogError(ex, "Failed to get bonus prediction for question {QuestionId} using model {Model} and commu
 0772            throw;
 773        }
 1774    }
 775
 776    public Task<BonusPrediction?> GetBonusPredictionByTextAsync(string questionText, string model, string communityConte
 777    {
 1778        return GetBonusPredictionByTextAsync(questionText, PredictionModelConfig.Create(model), communityContext, cancel
 779    }
 780
 781    public async Task<BonusPrediction?> GetBonusPredictionByTextAsync(string questionText, PredictionModelConfig modelCo
 782    {
 783        try
 784        {
 785            // Query by questionText, model, and community context
 786            // Order by repredictionIndex descending to get the latest version
 1787            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 1788                .WhereEqualTo("questionText", questionText)
 1789                .WhereEqualTo("competition", _competition)
 1790                .WhereEqualTo("model", modelConfig.Model)
 1791                .WhereEqualTo("communityContext", communityContext)
 1792                .OrderByDescending("repredictionIndex");
 793
 1794            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 1795            var firestoreBonusPrediction = SelectLatestForModelConfig(
 1796                snapshot.Documents.Select(document => document.ConvertTo<FirestoreBonusPrediction>()),
 1797                modelConfig);
 798
 1799            if (firestoreBonusPrediction is null)
 800            {
 1801                _logger.LogDebug("No bonus prediction found for question text: {QuestionText} with model: {Model} and co
 1802                return null;
 803            }
 804
 1805            var bonusPrediction = new BonusPrediction(firestoreBonusPrediction.SelectedOptionIds.ToList());
 806
 1807            _logger.LogDebug("Found bonus prediction for question text: {QuestionText} with model: {Model} and community
 1808                questionText, modelConfig.DisplayName, communityContext, firestoreBonusPrediction.RepredictionIndex);
 809
 1810            return bonusPrediction;
 811        }
 0812        catch (Exception ex)
 813        {
 0814            _logger.LogError(ex, "Failed to retrieve bonus prediction by text: {QuestionText} with model: {Model} and co
 0815            throw;
 816        }
 1817    }
 818
 819    public Task<BonusPredictionMetadata?> GetBonusPredictionMetadataByTextAsync(string questionText, string model, strin
 820    {
 1821        return GetBonusPredictionMetadataByTextAsync(questionText, PredictionModelConfig.Create(model), communityContext
 822    }
 823
 824    public async Task<BonusPredictionMetadata?> GetBonusPredictionMetadataByTextAsync(string questionText, PredictionMod
 825    {
 826        try
 827        {
 828            // Query by questionText, model, and community context.
 829            // Order by repredictionIndex descending to align metadata reads with latest bonus prediction retrieval.
 1830            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 1831                .WhereEqualTo("questionText", questionText)
 1832                .WhereEqualTo("competition", _competition)
 1833                .WhereEqualTo("model", modelConfig.Model)
 1834                .WhereEqualTo("communityContext", communityContext)
 1835                .OrderByDescending("repredictionIndex");
 836
 1837            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 1838            var firestoreBonusPrediction = SelectLatestForModelConfig(
 1839                snapshot.Documents.Select(document => document.ConvertTo<FirestoreBonusPrediction>()),
 1840                modelConfig);
 841
 1842            if (firestoreBonusPrediction is null)
 843            {
 0844                _logger.LogDebug("No bonus prediction metadata found for question text: {QuestionText} with model: {Mode
 0845                return null;
 846            }
 847
 1848            var bonusPrediction = new BonusPrediction(firestoreBonusPrediction.SelectedOptionIds.ToList());
 1849            var createdAt = firestoreBonusPrediction.CreatedAt.ToDateTimeOffset();
 1850            var contextDocumentNames = firestoreBonusPrediction.ContextDocumentNames?.ToList() ?? new List<string>();
 851
 1852            _logger.LogDebug("Found bonus prediction metadata for question text: {QuestionText} with model: {Model} and 
 1853                questionText, modelConfig.DisplayName, communityContext);
 854
 1855            return new BonusPredictionMetadata(bonusPrediction, createdAt, contextDocumentNames);
 856        }
 0857        catch (Exception ex)
 858        {
 0859            _logger.LogError(ex, "Failed to retrieve bonus prediction metadata by text: {QuestionText} with model: {Mode
 0860            throw;
 861        }
 1862    }
 863
 864    public Task<IReadOnlyList<BonusPrediction>> GetAllBonusPredictionsAsync(string model, string communityContext, Cance
 865    {
 1866        return GetAllBonusPredictionsAsync(PredictionModelConfig.Create(model), communityContext, cancellationToken);
 867    }
 868
 869    public async Task<IReadOnlyList<BonusPrediction>> GetAllBonusPredictionsAsync(PredictionModelConfig modelConfig, str
 870    {
 871        try
 872        {
 1873            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 1874                .WhereEqualTo("competition", _competition)
 1875                .WhereEqualTo("model", modelConfig.Model)
 1876                .WhereEqualTo("communityContext", communityContext)
 1877                .OrderBy("createdAt");
 878
 1879            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 880
 1881            var bonusPredictions = new List<BonusPrediction>();
 1882            foreach (var document in snapshot.Documents)
 883            {
 1884                var firestoreBonusPrediction = document.ConvertTo<FirestoreBonusPrediction>();
 1885                if (GetConfigMatchKind(firestoreBonusPrediction, modelConfig) == PredictionConfigMatchKind.None)
 886                {
 887                    continue;
 888                }
 889
 1890                bonusPredictions.Add(new BonusPrediction(
 1891                    firestoreBonusPrediction.SelectedOptionIds.ToList()));
 892            }
 893
 1894            return bonusPredictions.AsReadOnly();
 895        }
 0896        catch (Exception ex)
 897        {
 0898            _logger.LogError(ex, "Failed to get all bonus predictions for model {Model} and community context {Community
 0899            throw;
 900        }
 1901    }
 902
 903    public Task<bool> HasBonusPredictionAsync(string questionId, string model, string communityContext, CancellationToke
 904    {
 1905        return HasBonusPredictionAsync(questionId, PredictionModelConfig.Create(model), communityContext, cancellationTo
 906    }
 907
 908    public async Task<bool> HasBonusPredictionAsync(string questionId, PredictionModelConfig modelConfig, string communi
 909    {
 910        try
 911        {
 912            // Query by questionId, model, and community context instead of using direct document lookup
 1913            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 1914                .WhereEqualTo("questionId", questionId)
 1915                .WhereEqualTo("competition", _competition)
 1916                .WhereEqualTo("model", modelConfig.Model)
 1917                .WhereEqualTo("communityContext", communityContext);
 918
 1919            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 1920            return snapshot.Documents
 0921                .Select(document => document.ConvertTo<FirestoreBonusPrediction>())
 0922                .Any(prediction => GetConfigMatchKind(prediction, modelConfig) != PredictionConfigMatchKind.None);
 923        }
 0924        catch (Exception ex)
 925        {
 0926            _logger.LogError(ex, "Failed to check if bonus prediction exists for question {QuestionId} using model {Mode
 0927            throw;
 928        }
 1929    }
 930
 931    /// <summary>
 932    /// Stores a match in the matches collection for matchday management.
 933    /// This is typically called when importing match schedules.
 934    /// </summary>
 935    public async Task StoreMatchAsync(Match match, CancellationToken cancellationToken = default)
 936    {
 937        try
 938        {
 1939            var documentId = Guid.NewGuid().ToString();
 940
 1941            var firestoreMatch = new FirestoreMatch
 1942            {
 1943                Id = documentId,
 1944                HomeTeam = match.HomeTeam,
 1945                AwayTeam = match.AwayTeam,
 1946                StartsAt = ConvertToTimestamp(match.StartsAt),
 1947                Matchday = match.Matchday,
 1948                Competition = _competition,
 1949                IsCancelled = match.IsCancelled
 1950            };
 951
 1952            await _firestoreDb.Collection(_matchesCollection)
 1953                .Document(documentId)
 1954                .SetAsync(firestoreMatch, cancellationToken: cancellationToken);
 955
 1956            _logger.LogDebug("Stored match {HomeTeam} vs {AwayTeam} for matchday {Matchday}{Cancelled}",
 1957                match.HomeTeam, match.AwayTeam, match.Matchday, match.IsCancelled ? " (CANCELLED)" : "");
 1958        }
 0959        catch (Exception ex)
 960        {
 0961            _logger.LogError(ex, "Failed to store match {HomeTeam} vs {AwayTeam}",
 0962                match.HomeTeam, match.AwayTeam);
 0963            throw;
 964        }
 1965    }
 966
 967    private static Timestamp ConvertToTimestamp(ZonedDateTime zonedDateTime)
 968    {
 1969        var instant = zonedDateTime.ToInstant();
 1970        return Timestamp.FromDateTimeOffset(instant.ToDateTimeOffset());
 971    }
 972
 973    private static ZonedDateTime ConvertFromTimestamp(Timestamp timestamp)
 974    {
 1975        var dateTimeOffset = timestamp.ToDateTimeOffset();
 1976        var instant = Instant.FromDateTimeOffset(dateTimeOffset);
 1977        return instant.InUtc();
 978    }
 979
 980    public Task<int> GetMatchRepredictionIndexAsync(Match match, string model, string communityContext, CancellationToke
 981    {
 1982        return GetMatchRepredictionIndexAsync(match, PredictionModelConfig.Create(model), communityContext, cancellation
 983    }
 984
 985    public async Task<int> GetMatchRepredictionIndexAsync(Match match, PredictionModelConfig modelConfig, string communi
 986    {
 987        try
 988        {
 989            // Query by match characteristics, model, community context, and competition
 990            // Order by repredictionIndex descending to get the latest version
 1991            var query = _firestoreDb.Collection(_predictionsCollection)
 1992                .WhereEqualTo("homeTeam", match.HomeTeam)
 1993                .WhereEqualTo("awayTeam", match.AwayTeam)
 1994                .WhereEqualTo("startsAt", ConvertToTimestamp(match.StartsAt))
 1995                .WhereEqualTo("competition", _competition)
 1996                .WhereEqualTo("model", modelConfig.Model)
 1997                .WhereEqualTo("communityContext", communityContext)
 1998                .OrderByDescending("repredictionIndex");
 999
 11000            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 11001            var firestorePrediction = SelectLatestForModelConfig(
 11002                snapshot.Documents.Select(document => document.ConvertTo<FirestoreMatchPrediction>()),
 11003                modelConfig);
 1004
 11005            if (firestorePrediction is null)
 1006            {
 11007                return -1; // No prediction exists
 1008            }
 1009
 11010            return firestorePrediction.RepredictionIndex;
 1011        }
 01012        catch (Exception ex)
 1013        {
 01014            _logger.LogError(ex, "Failed to get reprediction index for match {HomeTeam} vs {AwayTeam} using model {Model
 01015                match.HomeTeam, match.AwayTeam, modelConfig.DisplayName, communityContext);
 01016            throw;
 1017        }
 11018    }
 1019
 1020    // See IPredictionRepository.cs for detailed documentation on why these methods exist.
 1021    // In short: cancelled matches have inconsistent startsAt values across different Kicktipp pages,
 1022    // so we query by team names only to find predictions regardless of which startsAt was used.
 1023
 1024    /// <inheritdoc />
 1025    public Task<Prediction?> GetCancelledMatchPredictionAsync(string homeTeam, string awayTeam, string model, string com
 1026    {
 11027        return GetCancelledMatchPredictionAsync(homeTeam, awayTeam, PredictionModelConfig.Create(model), communityContex
 1028    }
 1029
 1030    public async Task<Prediction?> GetCancelledMatchPredictionAsync(string homeTeam, string awayTeam, PredictionModelCon
 1031    {
 1032        try
 1033        {
 1034            // Query by team names only (no startsAt), ordered by createdAt descending to get the most recent
 1035            // We use repredictionIndex descending first to get the latest reprediction, then createdAt for tiebreaking
 11036            var query = _firestoreDb.Collection(_predictionsCollection)
 11037                .WhereEqualTo("homeTeam", homeTeam)
 11038                .WhereEqualTo("awayTeam", awayTeam)
 11039                .WhereEqualTo("competition", _competition)
 11040                .WhereEqualTo("model", modelConfig.Model)
 11041                .WhereEqualTo("communityContext", communityContext)
 11042                .OrderByDescending("createdAt");
 1043
 11044            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 11045            var firestorePrediction = SelectLatestForModelConfig(
 11046                snapshot.Documents.Select(document => document.ConvertTo<FirestoreMatchPrediction>()),
 11047                modelConfig);
 1048
 11049            if (firestorePrediction is null)
 1050            {
 11051                _logger.LogDebug("No prediction found for cancelled match {HomeTeam} vs {AwayTeam} (team-names-only look
 11052                return null;
 1053            }
 1054
 11055            _logger.LogDebug("Found prediction for cancelled match {HomeTeam} vs {AwayTeam} with startsAt={StartsAt} (te
 11056                homeTeam, awayTeam, firestorePrediction.StartsAt);
 1057
 11058            return new Prediction(
 11059                firestorePrediction.HomeGoals,
 11060                firestorePrediction.AwayGoals,
 11061                DeserializeJustification(firestorePrediction.Justification));
 1062        }
 01063        catch (Exception ex)
 1064        {
 01065            _logger.LogError(ex, "Failed to get prediction for cancelled match {HomeTeam} vs {AwayTeam} using model {Mod
 01066                homeTeam, awayTeam, modelConfig.DisplayName, communityContext);
 01067            throw;
 1068        }
 11069    }
 1070
 1071    /// <inheritdoc />
 1072    public Task<PredictionMetadata?> GetCancelledMatchPredictionMetadataAsync(string homeTeam, string awayTeam, string m
 1073    {
 11074        return GetCancelledMatchPredictionMetadataAsync(homeTeam, awayTeam, PredictionModelConfig.Create(model), communi
 1075    }
 1076
 1077    public async Task<PredictionMetadata?> GetCancelledMatchPredictionMetadataAsync(string homeTeam, string awayTeam, Pr
 1078    {
 1079        try
 1080        {
 1081            // Query by team names only (no startsAt), ordered by repredictionIndex descending to get the latest repredi
 11082            var query = _firestoreDb.Collection(_predictionsCollection)
 11083                .WhereEqualTo("homeTeam", homeTeam)
 11084                .WhereEqualTo("awayTeam", awayTeam)
 11085                .WhereEqualTo("competition", _competition)
 11086                .WhereEqualTo("model", modelConfig.Model)
 11087                .WhereEqualTo("communityContext", communityContext)
 11088                .OrderByDescending("repredictionIndex");
 1089
 11090            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 11091            var firestorePrediction = SelectLatestForModelConfig(
 11092                snapshot.Documents.Select(document => document.ConvertTo<FirestoreMatchPrediction>()),
 11093                modelConfig);
 1094
 11095            if (firestorePrediction is null)
 1096            {
 11097                _logger.LogDebug("No prediction metadata found for cancelled match {HomeTeam} vs {AwayTeam} (team-names-
 11098                return null;
 1099            }
 1100
 11101            _logger.LogDebug("Found prediction metadata for cancelled match {HomeTeam} vs {AwayTeam} with startsAt={Star
 11102                homeTeam, awayTeam, firestorePrediction.StartsAt);
 1103
 11104            var prediction = new Prediction(
 11105                firestorePrediction.HomeGoals,
 11106                firestorePrediction.AwayGoals,
 11107                DeserializeJustification(firestorePrediction.Justification));
 11108            var createdAt = firestorePrediction.CreatedAt.ToDateTimeOffset();
 11109            var contextDocumentNames = firestorePrediction.ContextDocumentNames?.ToList() ?? new List<string>();
 1110
 11111            return new PredictionMetadata(prediction, createdAt, contextDocumentNames);
 1112        }
 01113        catch (Exception ex)
 1114        {
 01115            _logger.LogError(ex, "Failed to get prediction metadata for cancelled match {HomeTeam} vs {AwayTeam} using m
 01116                homeTeam, awayTeam, modelConfig.DisplayName, communityContext);
 01117            throw;
 1118        }
 11119    }
 1120
 1121    /// <inheritdoc />
 1122    public Task<int> GetCancelledMatchRepredictionIndexAsync(string homeTeam, string awayTeam, string model, string comm
 1123    {
 11124        return GetCancelledMatchRepredictionIndexAsync(homeTeam, awayTeam, PredictionModelConfig.Create(model), communit
 1125    }
 1126
 1127    public async Task<int> GetCancelledMatchRepredictionIndexAsync(string homeTeam, string awayTeam, PredictionModelConf
 1128    {
 1129        try
 1130        {
 1131            // Query by team names only (no startsAt), ordered by repredictionIndex descending to get the highest
 11132            var query = _firestoreDb.Collection(_predictionsCollection)
 11133                .WhereEqualTo("homeTeam", homeTeam)
 11134                .WhereEqualTo("awayTeam", awayTeam)
 11135                .WhereEqualTo("competition", _competition)
 11136                .WhereEqualTo("model", modelConfig.Model)
 11137                .WhereEqualTo("communityContext", communityContext)
 11138                .OrderByDescending("repredictionIndex");
 1139
 11140            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 11141            var firestorePrediction = SelectLatestForModelConfig(
 11142                snapshot.Documents.Select(document => document.ConvertTo<FirestoreMatchPrediction>()),
 11143                modelConfig);
 1144
 11145            if (firestorePrediction is null)
 1146            {
 11147                _logger.LogDebug("No reprediction index found for cancelled match {HomeTeam} vs {AwayTeam} (team-names-o
 11148                return -1;
 1149            }
 1150
 11151            _logger.LogDebug("Found reprediction index {Index} for cancelled match {HomeTeam} vs {AwayTeam} with startsA
 11152                firestorePrediction.RepredictionIndex, homeTeam, awayTeam, firestorePrediction.StartsAt);
 1153
 11154            return firestorePrediction.RepredictionIndex;
 1155        }
 01156        catch (Exception ex)
 1157        {
 01158            _logger.LogError(ex, "Failed to get reprediction index for cancelled match {HomeTeam} vs {AwayTeam} using mo
 01159                homeTeam, awayTeam, modelConfig.DisplayName, communityContext);
 01160            throw;
 1161        }
 11162    }
 1163
 1164    public Task<int> GetBonusRepredictionIndexAsync(string questionText, string model, string communityContext, Cancella
 1165    {
 11166        return GetBonusRepredictionIndexAsync(questionText, PredictionModelConfig.Create(model), communityContext, cance
 1167    }
 1168
 1169    public async Task<int> GetBonusRepredictionIndexAsync(string questionText, PredictionModelConfig modelConfig, string
 1170    {
 1171        try
 1172        {
 1173            // Query by question text, model, community context, and competition
 1174            // Order by repredictionIndex descending to get the latest version
 11175            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 11176                .WhereEqualTo("questionText", questionText)
 11177                .WhereEqualTo("competition", _competition)
 11178                .WhereEqualTo("model", modelConfig.Model)
 11179                .WhereEqualTo("communityContext", communityContext)
 11180                .OrderByDescending("repredictionIndex");
 1181
 11182            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 11183            var firestorePrediction = SelectLatestForModelConfig(
 11184                snapshot.Documents.Select(document => document.ConvertTo<FirestoreBonusPrediction>()),
 11185                modelConfig);
 1186
 11187            if (firestorePrediction is null)
 1188            {
 11189                return -1; // No prediction exists
 1190            }
 1191
 11192            return firestorePrediction.RepredictionIndex;
 1193        }
 01194        catch (Exception ex)
 1195        {
 01196            _logger.LogError(ex, "Failed to get reprediction index for bonus question '{QuestionText}' using model {Mode
 01197                questionText, modelConfig.DisplayName, communityContext);
 01198            throw;
 1199        }
 11200    }
 1201
 1202    public Task SaveRepredictionAsync(Match match, Prediction prediction, string model, string tokenUsage, double cost, 
 1203    {
 11204        return SaveRepredictionAsync(
 11205            match,
 11206            prediction,
 11207            PredictionModelConfig.Create(model),
 11208            tokenUsage,
 11209            cost,
 11210            communityContext,
 11211            contextDocumentNames,
 11212            repredictionIndex,
 11213            cancellationToken);
 1214    }
 1215
 1216    public async Task SaveRepredictionAsync(Match match, Prediction prediction, PredictionModelConfig modelConfig, strin
 1217    {
 1218        try
 1219        {
 11220            var now = Timestamp.GetCurrentTimestamp();
 1221
 1222            // Create new document for this reprediction
 11223            var documentId = Guid.NewGuid().ToString();
 11224            var docRef = _firestoreDb.Collection(_predictionsCollection).Document(documentId);
 1225
 11226            _logger.LogDebug("Creating reprediction for match {HomeTeam} vs {AwayTeam} (document: {DocumentId}, repredic
 11227                match.HomeTeam, match.AwayTeam, documentId, repredictionIndex);
 1228
 11229            var firestorePrediction = new FirestoreMatchPrediction
 11230            {
 11231                Id = docRef.Id,
 11232                HomeTeam = match.HomeTeam,
 11233                AwayTeam = match.AwayTeam,
 11234                StartsAt = ConvertToTimestamp(match.StartsAt),
 11235                Matchday = match.Matchday,
 11236                HomeGoals = prediction.HomeGoals,
 11237                AwayGoals = prediction.AwayGoals,
 11238                Justification = SerializeJustification(prediction.Justification),
 11239                CreatedAt = now,
 11240                UpdatedAt = now,
 11241                Competition = _competition,
 11242                Model = modelConfig.Model,
 11243                ModelConfigKey = modelConfig.IdentityKey,
 11244                ReasoningEffort = modelConfig.ReasoningEffort,
 11245                TokenUsage = tokenUsage,
 11246                Cost = cost,
 11247                CommunityContext = communityContext,
 11248                ContextDocumentNames = contextDocumentNames.ToArray(),
 11249                RepredictionIndex = repredictionIndex
 11250            };
 1251
 11252            await docRef.SetAsync(firestorePrediction, cancellationToken: cancellationToken);
 1253
 11254            _logger.LogInformation("Saved reprediction for match {HomeTeam} vs {AwayTeam} on matchday {Matchday} (repred
 11255                match.HomeTeam, match.AwayTeam, match.Matchday, repredictionIndex);
 11256        }
 01257        catch (Exception ex)
 1258        {
 01259            _logger.LogError(ex, "Failed to save reprediction for match {HomeTeam} vs {AwayTeam}",
 01260                match.HomeTeam, match.AwayTeam);
 01261            throw;
 1262        }
 11263    }
 1264
 1265    public Task SaveBonusRepredictionAsync(BonusQuestion bonusQuestion, BonusPrediction bonusPrediction, string model, s
 1266    {
 11267        return SaveBonusRepredictionAsync(
 11268            bonusQuestion,
 11269            bonusPrediction,
 11270            PredictionModelConfig.Create(model),
 11271            tokenUsage,
 11272            cost,
 11273            communityContext,
 11274            contextDocumentNames,
 11275            repredictionIndex,
 11276            cancellationToken);
 1277    }
 1278
 1279    public async Task SaveBonusRepredictionAsync(BonusQuestion bonusQuestion, BonusPrediction bonusPrediction, Predictio
 1280    {
 1281        try
 1282        {
 11283            var now = Timestamp.GetCurrentTimestamp();
 1284
 1285            // Create new document for this reprediction
 11286            var documentId = Guid.NewGuid().ToString();
 11287            var docRef = _firestoreDb.Collection(_bonusPredictionsCollection).Document(documentId);
 1288
 11289            _logger.LogDebug("Creating bonus reprediction for question '{QuestionText}' (document: {DocumentId}, repredi
 11290                bonusQuestion.Text, documentId, repredictionIndex);
 1291
 1292            // Extract selected option texts for observability
 11293            var optionTextsLookup = bonusQuestion.Options.ToDictionary(o => o.Id, o => o.Text);
 11294            var selectedOptionTexts = bonusPrediction.SelectedOptionIds
 11295                .Select(id => optionTextsLookup.TryGetValue(id, out var text) ? text : $"Unknown option: {id}")
 11296                .ToArray();
 1297
 11298            var firestoreBonusPrediction = new FirestoreBonusPrediction
 11299            {
 11300                Id = docRef.Id,
 11301                QuestionText = bonusQuestion.Text,
 11302                SelectedOptionIds = bonusPrediction.SelectedOptionIds.ToArray(),
 11303                SelectedOptionTexts = selectedOptionTexts,
 11304                CreatedAt = now,
 11305                UpdatedAt = now,
 11306                Competition = _competition,
 11307                Model = modelConfig.Model,
 11308                ModelConfigKey = modelConfig.IdentityKey,
 11309                ReasoningEffort = modelConfig.ReasoningEffort,
 11310                TokenUsage = tokenUsage,
 11311                Cost = cost,
 11312                CommunityContext = communityContext,
 11313                ContextDocumentNames = contextDocumentNames.ToArray(),
 11314                RepredictionIndex = repredictionIndex
 11315            };
 1316
 11317            await docRef.SetAsync(firestoreBonusPrediction, cancellationToken: cancellationToken);
 1318
 11319            _logger.LogInformation("Saved bonus reprediction for question '{QuestionText}' (reprediction index: {Repredi
 11320                bonusQuestion.Text, repredictionIndex);
 11321        }
 01322        catch (Exception ex)
 1323        {
 01324            _logger.LogError(ex, "Failed to save bonus reprediction for question: {QuestionText}",
 01325                bonusQuestion.Text);
 01326            throw;
 1327        }
 11328    }
 1329
 1330    /// <summary>
 1331    /// Get match prediction costs and counts grouped by reprediction index for cost analysis.
 1332    /// Used specifically by the cost command to include all repredictions.
 1333    /// </summary>
 1334    public Task<Dictionary<int, (double cost, int count)>> GetMatchPredictionCostsByRepredictionIndexAsync(
 1335        string model,
 1336        string communityContext,
 1337        List<int>? matchdays = null,
 1338        CancellationToken cancellationToken = default)
 1339    {
 11340        return GetMatchPredictionCostsByRepredictionIndexAsync(
 11341            PredictionModelConfig.Create(model),
 11342            communityContext,
 11343            matchdays,
 11344            cancellationToken);
 1345    }
 1346
 1347    public async Task<Dictionary<int, (double cost, int count)>> GetMatchPredictionCostsByRepredictionIndexAsync(
 1348        PredictionModelConfig modelConfig,
 1349        string communityContext,
 1350        List<int>? matchdays = null,
 1351        CancellationToken cancellationToken = default)
 1352    {
 1353        try
 1354        {
 11355            var costsByIndex = new Dictionary<int, (double cost, int count)>();
 1356
 1357            // Query for match predictions with cost data
 11358            var query = _firestoreDb.Collection(_predictionsCollection)
 11359                .WhereEqualTo("competition", _competition)
 11360                .WhereEqualTo("model", modelConfig.Model)
 11361                .WhereEqualTo("communityContext", communityContext);
 1362
 1363            // Add matchday filter if specified
 11364            if (matchdays?.Count > 0)
 1365            {
 11366                query = query.WhereIn("matchday", matchdays.Cast<object>().ToArray());
 1367            }
 1368
 11369            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 1370
 11371            foreach (var doc in snapshot.Documents)
 1372            {
 11373                if (doc.Exists)
 1374                {
 11375                    var prediction = doc.ConvertTo<FirestoreMatchPrediction>();
 11376                    if (GetConfigMatchKind(prediction, modelConfig) == PredictionConfigMatchKind.None)
 1377                    {
 1378                        continue;
 1379                    }
 1380
 11381                    var repredictionIndex = prediction.RepredictionIndex;
 1382
 11383                    if (!costsByIndex.ContainsKey(repredictionIndex))
 1384                    {
 11385                        costsByIndex[repredictionIndex] = (0.0, 0);
 1386                    }
 1387
 11388                    var (currentCost, currentCount) = costsByIndex[repredictionIndex];
 11389                    costsByIndex[repredictionIndex] = (currentCost + prediction.Cost, currentCount + 1);
 1390                }
 1391            }
 1392
 11393            return costsByIndex;
 1394        }
 01395        catch (Exception ex)
 1396        {
 01397            _logger.LogError(ex, "Failed to get match prediction costs by reprediction index for model {Model} and commu
 01398                modelConfig.DisplayName, communityContext);
 01399            throw;
 1400        }
 11401    }
 1402
 1403    /// <summary>
 1404    /// Get bonus prediction costs and counts grouped by reprediction index for cost analysis.
 1405    /// Used specifically by the cost command to include all repredictions.
 1406    /// </summary>
 1407    public Task<Dictionary<int, (double cost, int count)>> GetBonusPredictionCostsByRepredictionIndexAsync(
 1408        string model,
 1409        string communityContext,
 1410        CancellationToken cancellationToken = default)
 1411    {
 11412        return GetBonusPredictionCostsByRepredictionIndexAsync(
 11413            PredictionModelConfig.Create(model),
 11414            communityContext,
 11415            cancellationToken);
 1416    }
 1417
 1418    public async Task<Dictionary<int, (double cost, int count)>> GetBonusPredictionCostsByRepredictionIndexAsync(
 1419        PredictionModelConfig modelConfig,
 1420        string communityContext,
 1421        CancellationToken cancellationToken = default)
 1422    {
 1423        try
 1424        {
 11425            var costsByIndex = new Dictionary<int, (double cost, int count)>();
 1426
 1427            // Query for bonus predictions with cost data
 11428            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 11429                .WhereEqualTo("competition", _competition)
 11430                .WhereEqualTo("model", modelConfig.Model)
 11431                .WhereEqualTo("communityContext", communityContext);
 1432
 11433            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 1434
 11435            foreach (var doc in snapshot.Documents)
 1436            {
 11437                if (doc.Exists)
 1438                {
 11439                    var prediction = doc.ConvertTo<FirestoreBonusPrediction>();
 11440                    if (GetConfigMatchKind(prediction, modelConfig) == PredictionConfigMatchKind.None)
 1441                    {
 1442                        continue;
 1443                    }
 1444
 11445                    var repredictionIndex = prediction.RepredictionIndex;
 1446
 11447                    if (!costsByIndex.ContainsKey(repredictionIndex))
 1448                    {
 11449                        costsByIndex[repredictionIndex] = (0.0, 0);
 1450                    }
 1451
 11452                    var (currentCost, currentCount) = costsByIndex[repredictionIndex];
 11453                    costsByIndex[repredictionIndex] = (currentCost + prediction.Cost, currentCount + 1);
 1454                }
 1455            }
 1456
 11457            return costsByIndex;
 1458        }
 01459        catch (Exception ex)
 1460        {
 01461            _logger.LogError(ex, "Failed to get bonus prediction costs by reprediction index for model {Model} and commu
 01462                modelConfig.DisplayName, communityContext);
 01463            throw;
 1464        }
 11465    }
 1466
 1467    /// <inheritdoc />
 1468    public async Task<List<int>> GetAvailableMatchdaysAsync(CancellationToken cancellationToken = default)
 1469    {
 1470        try
 1471        {
 11472            var matchdays = new HashSet<int>();
 1473
 1474            // Query match predictions for unique matchdays
 11475            var query = _firestoreDb.Collection(_predictionsCollection)
 11476                .WhereEqualTo("competition", _competition);
 11477            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 1478
 11479            foreach (var doc in snapshot.Documents)
 1480            {
 11481                if (doc.TryGetValue<int>("matchday", out var matchday) && matchday > 0)
 1482                {
 11483                    matchdays.Add(matchday);
 1484                }
 1485            }
 1486
 11487            return matchdays.OrderBy(m => m).ToList();
 1488        }
 01489        catch (Exception ex)
 1490        {
 01491            _logger.LogError(ex, "Failed to get available matchdays");
 01492            throw;
 1493        }
 11494    }
 1495
 1496    /// <inheritdoc />
 1497    public async Task<List<string>> GetAvailableModelsAsync(CancellationToken cancellationToken = default)
 1498    {
 1499        try
 1500        {
 11501            var models = new HashSet<string>();
 1502
 1503            // Query match predictions for unique models
 11504            var matchQuery = _firestoreDb.Collection(_predictionsCollection)
 11505                .WhereEqualTo("competition", _competition);
 11506            var matchSnapshot = await matchQuery.GetSnapshotAsync(cancellationToken);
 1507
 11508            foreach (var doc in matchSnapshot.Documents)
 1509            {
 11510                if (doc.TryGetValue<string>("model", out var model) && !string.IsNullOrWhiteSpace(model))
 1511                {
 11512                    models.Add(model);
 1513                }
 1514            }
 1515
 1516            // Query bonus predictions for unique models
 11517            var bonusQuery = _firestoreDb.Collection(_bonusPredictionsCollection)
 11518                .WhereEqualTo("competition", _competition);
 11519            var bonusSnapshot = await bonusQuery.GetSnapshotAsync(cancellationToken);
 1520
 11521            foreach (var doc in bonusSnapshot.Documents)
 1522            {
 11523                if (doc.TryGetValue<string>("model", out var model) && !string.IsNullOrWhiteSpace(model))
 1524                {
 11525                    models.Add(model);
 1526                }
 1527            }
 1528
 11529            return models.OrderBy(model => model, StringComparer.Ordinal).ToList();
 1530        }
 01531        catch (Exception ex)
 1532        {
 01533            _logger.LogError(ex, "Failed to get available models");
 01534            throw;
 1535        }
 11536    }
 1537
 1538    /// <inheritdoc />
 1539    public async Task<List<PredictionModelConfig>> GetAvailableModelConfigsAsync(CancellationToken cancellationToken = d
 1540    {
 1541        try
 1542        {
 11543            var modelConfigs = new Dictionary<string, PredictionModelConfig>(StringComparer.Ordinal);
 1544
 11545            var matchQuery = _firestoreDb.Collection(_predictionsCollection)
 11546                .WhereEqualTo("competition", _competition);
 11547            var matchSnapshot = await matchQuery.GetSnapshotAsync(cancellationToken);
 1548
 11549            foreach (var doc in matchSnapshot.Documents)
 1550            {
 11551                AddModelConfigIfValid(modelConfigs, doc.ConvertTo<FirestoreMatchPrediction>());
 1552            }
 1553
 11554            var bonusQuery = _firestoreDb.Collection(_bonusPredictionsCollection)
 11555                .WhereEqualTo("competition", _competition);
 11556            var bonusSnapshot = await bonusQuery.GetSnapshotAsync(cancellationToken);
 1557
 11558            foreach (var doc in bonusSnapshot.Documents)
 1559            {
 11560                AddModelConfigIfValid(modelConfigs, doc.ConvertTo<FirestoreBonusPrediction>());
 1561            }
 1562
 11563            return modelConfigs.Values
 11564                .OrderBy(config => config.Model, StringComparer.Ordinal)
 11565                .ThenBy(config => config.ReasoningEffort is null ? string.Empty : config.ReasoningEffort, StringComparer
 11566                .ToList();
 1567        }
 01568        catch (Exception ex)
 1569        {
 01570            _logger.LogError(ex, "Failed to get available model configs");
 01571            throw;
 1572        }
 11573    }
 1574
 1575    private static void AddModelConfigIfValid(Dictionary<string, PredictionModelConfig> modelConfigs, FirestoreMatchPred
 1576    {
 11577        AddModelConfigIfValid(modelConfigs, prediction.Model, prediction.ReasoningEffort);
 11578    }
 1579
 1580    private static void AddModelConfigIfValid(Dictionary<string, PredictionModelConfig> modelConfigs, FirestoreBonusPred
 1581    {
 11582        AddModelConfigIfValid(modelConfigs, prediction.Model, prediction.ReasoningEffort);
 11583    }
 1584
 1585    private static void AddModelConfigIfValid(Dictionary<string, PredictionModelConfig> modelConfigs, string model, stri
 1586    {
 11587        if (string.IsNullOrWhiteSpace(model))
 1588        {
 01589            return;
 1590        }
 1591
 11592        if (!PredictionModelConfig.IsValidReasoningEffort(reasoningEffort))
 1593        {
 01594            return;
 1595        }
 1596
 11597        var modelConfig = PredictionModelConfig.Create(model, reasoningEffort);
 11598        modelConfigs.TryAdd(modelConfig.IdentityKey, modelConfig);
 11599    }
 1600
 1601    /// <inheritdoc />
 1602    public async Task<List<string>> GetAvailableCommunityContextsAsync(CancellationToken cancellationToken = default)
 1603    {
 1604        try
 1605        {
 11606            var communityContexts = new HashSet<string>();
 1607
 1608            // Query match predictions for unique community contexts
 11609            var matchQuery = _firestoreDb.Collection(_predictionsCollection)
 11610                .WhereEqualTo("competition", _competition);
 11611            var matchSnapshot = await matchQuery.GetSnapshotAsync(cancellationToken);
 1612
 11613            foreach (var doc in matchSnapshot.Documents)
 1614            {
 11615                if (doc.TryGetValue<string>("communityContext", out var context) && !string.IsNullOrWhiteSpace(context))
 1616                {
 11617                    communityContexts.Add(context);
 1618                }
 1619            }
 1620
 1621            // Query bonus predictions for unique community contexts
 11622            var bonusQuery = _firestoreDb.Collection(_bonusPredictionsCollection)
 11623                .WhereEqualTo("competition", _competition);
 11624            var bonusSnapshot = await bonusQuery.GetSnapshotAsync(cancellationToken);
 1625
 11626            foreach (var doc in bonusSnapshot.Documents)
 1627            {
 11628                if (doc.TryGetValue<string>("communityContext", out var context) && !string.IsNullOrWhiteSpace(context))
 1629                {
 11630                    communityContexts.Add(context);
 1631                }
 1632            }
 1633
 11634            return communityContexts.OrderBy(context => context, StringComparer.Ordinal).ToList();
 1635        }
 01636        catch (Exception ex)
 1637        {
 01638            _logger.LogError(ex, "Failed to get available community contexts");
 01639            throw;
 1640        }
 11641    }
 1642
 1643    private string? SerializeJustification(PredictionJustification? justification)
 1644    {
 11645        if (justification == null)
 1646        {
 11647            return null;
 1648        }
 1649
 11650        if (!HasJustificationContent(justification))
 1651        {
 11652            return null;
 1653        }
 1654
 11655        var stored = new StoredJustification
 11656        {
 11657            KeyReasoning = justification.KeyReasoning?.Trim() ?? string.Empty,
 11658            ContextSources = new StoredContextSources
 11659            {
 11660                MostValuable = justification.ContextSources?.MostValuable?
 11661                    .Where(entry => entry != null)
 11662                    .Select(ToStoredContextSource)
 11663                    .ToList() ?? new List<StoredContextSource>(),
 11664                LeastValuable = justification.ContextSources?.LeastValuable?
 11665                    .Where(entry => entry != null)
 11666                    .Select(ToStoredContextSource)
 11667                    .ToList() ?? new List<StoredContextSource>()
 11668            },
 11669            Uncertainties = justification.Uncertainties?
 11670                .Where(item => !string.IsNullOrWhiteSpace(item))
 11671                .Select(item => item.Trim())
 11672                .ToList() ?? new List<string>()
 11673        };
 1674
 11675        return JsonSerializer.Serialize(stored, JustificationSerializerOptions);
 1676    }
 1677
 1678    private static bool HasJustificationContent(PredictionJustification justification)
 1679    {
 11680        if (!string.IsNullOrWhiteSpace(justification.KeyReasoning))
 1681        {
 11682            return true;
 1683        }
 1684
 11685        if (justification.ContextSources?.MostValuable != null &&
 11686            justification.ContextSources.MostValuable.Any(HasSourceContent))
 1687        {
 11688            return true;
 1689        }
 1690
 11691        if (justification.ContextSources?.LeastValuable != null &&
 11692            justification.ContextSources.LeastValuable.Any(HasSourceContent))
 1693        {
 11694            return true;
 1695        }
 1696
 11697        return justification.Uncertainties != null &&
 11698               justification.Uncertainties.Any(item => !string.IsNullOrWhiteSpace(item));
 1699    }
 1700
 1701    private static bool HasSourceContent(PredictionJustificationContextSource source)
 1702    {
 11703        return !string.IsNullOrWhiteSpace(source?.DocumentName) ||
 11704               !string.IsNullOrWhiteSpace(source?.Details);
 1705    }
 1706
 1707    private PredictionJustification? DeserializeJustification(string? serialized)
 1708    {
 11709        if (string.IsNullOrWhiteSpace(serialized))
 1710        {
 11711            return null;
 1712        }
 1713
 11714        var trimmed = serialized.Trim();
 1715
 11716        if (!trimmed.StartsWith("{"))
 1717        {
 11718            return new PredictionJustification(
 11719                trimmed,
 11720                new PredictionJustificationContextSources(
 11721                    Array.Empty<PredictionJustificationContextSource>(),
 11722                    Array.Empty<PredictionJustificationContextSource>()),
 11723                Array.Empty<string>());
 1724        }
 1725
 1726        try
 1727        {
 11728            var stored = JsonSerializer.Deserialize<StoredJustification>(trimmed, JustificationSerializerOptions);
 1729
 11730            if (stored == null)
 1731            {
 01732                return null;
 1733            }
 1734
 11735            var contextSources = stored.ContextSources ?? new StoredContextSources();
 1736
 11737            var mostValuable = contextSources.MostValuable?
 11738                .Where(entry => entry != null)
 11739                .Select(ToDomainContextSource)
 11740                .ToList() ?? new List<PredictionJustificationContextSource>();
 1741
 11742            var leastValuable = contextSources.LeastValuable?
 11743                .Where(entry => entry != null)
 11744                .Select(ToDomainContextSource)
 11745                .ToList() ?? new List<PredictionJustificationContextSource>();
 1746
 11747            var uncertainties = stored.Uncertainties?
 11748                .Where(item => !string.IsNullOrWhiteSpace(item))
 11749                .Select(item => item.Trim())
 11750                .ToList() ?? new List<string>();
 1751
 11752            var justification = new PredictionJustification(
 11753                stored.KeyReasoning?.Trim() ?? string.Empty,
 11754                new PredictionJustificationContextSources(mostValuable, leastValuable),
 11755                uncertainties);
 1756
 11757            return HasJustificationContent(justification) ? justification : null;
 1758        }
 11759        catch (JsonException ex)
 1760        {
 11761            _logger.LogWarning(ex, "Failed to parse structured justification JSON; falling back to legacy text format");
 1762
 11763            var fallbackJustification = new PredictionJustification(
 11764                trimmed,
 11765                new PredictionJustificationContextSources(
 11766                    Array.Empty<PredictionJustificationContextSource>(),
 11767                    Array.Empty<PredictionJustificationContextSource>()),
 11768                Array.Empty<string>());
 1769
 11770            return HasJustificationContent(fallbackJustification) ? fallbackJustification : null;
 1771        }
 11772    }
 1773
 1774    private static StoredContextSource ToStoredContextSource(PredictionJustificationContextSource source)
 1775    {
 11776        return new StoredContextSource
 11777        {
 11778            DocumentName = source.DocumentName?.Trim() ?? string.Empty,
 11779            Details = source.Details?.Trim() ?? string.Empty
 11780        };
 1781    }
 1782
 1783    private static PredictionJustificationContextSource ToDomainContextSource(StoredContextSource source)
 1784    {
 11785        var documentName = source.DocumentName?.Trim() ?? string.Empty;
 11786        var details = source.Details?.Trim() ?? string.Empty;
 11787        return new PredictionJustificationContextSource(documentName, details);
 1788    }
 1789
 1790    private sealed class StoredJustification
 1791    {
 1792        public string? KeyReasoning { get; set; }
 1793        public StoredContextSources? ContextSources { get; set; }
 1794        public List<string>? Uncertainties { get; set; }
 1795    }
 1796
 1797    private sealed class StoredContextSources
 1798    {
 1799        public List<StoredContextSource>? MostValuable { get; set; }
 1800        public List<StoredContextSource>? LeastValuable { get; set; }
 1801    }
 1802
 1803    private sealed class StoredContextSource
 1804    {
 1805        public string? DocumentName { get; set; }
 1806        public string? Details { get; set; }
 1807    }
 1808}

Methods/Properties

.cctor()
.ctor(Google.Cloud.Firestore.FirestoreDb, Microsoft.Extensions.Logging.ILogger<FirebaseAdapter.FirebasePredictionRepository>, string)
GetConfigMatchKind(FirebaseAdapter.Models.FirestoreMatchPrediction, EHonda.KicktippAi.Core.PredictionModelConfig)
GetConfigMatchKind(FirebaseAdapter.Models.FirestoreBonusPrediction, EHonda.KicktippAi.Core.PredictionModelConfig)
GetConfigMatchKind(string, string, EHonda.KicktippAi.Core.PredictionModelConfig)
SelectLatestForModelConfig(System.Collections.Generic.IEnumerable<FirebaseAdapter.Models.FirestoreMatchPrediction>, EHonda.KicktippAi.Core.PredictionModelConfig)
SelectLatestForModelConfig(System.Collections.Generic.IEnumerable<FirebaseAdapter.Models.FirestoreBonusPrediction>, EHonda.KicktippAi.Core.PredictionModelConfig)
SavePredictionAsync(EHonda.KicktippAi.Core.Match, EHonda.KicktippAi.Core.Prediction, string, string, double, string, System.Collections.Generic.IEnumerable<string>, bool, System.Threading.CancellationToken)
SavePredictionAsync()
GetPredictionAsync(EHonda.KicktippAi.Core.Match, string, string, System.Threading.CancellationToken)
GetPredictionAsync()
GetPredictionAsync(string, string, NodaTime.ZonedDateTime, string, string, System.Threading.CancellationToken)
GetPredictionAsync()
GetLatestPredictedMatchByTeamsAsync()
GetPredictionMetadataAsync(EHonda.KicktippAi.Core.Match, string, string, System.Threading.CancellationToken)
GetPredictionMetadataAsync()
GetMatchDayAsync()
GetStoredMatchAsync(string, string, int, string, string, System.Threading.CancellationToken)
GetStoredMatchAsync()
GetMatchDayWithPredictionsAsync(int, string, string, System.Threading.CancellationToken)
GetMatchDayWithPredictionsAsync()
GetAllPredictionsAsync(string, string, System.Threading.CancellationToken)
GetAllPredictionsAsync()
HasPredictionAsync(EHonda.KicktippAi.Core.Match, string, string, System.Threading.CancellationToken)
HasPredictionAsync()
SaveBonusPredictionAsync(EHonda.KicktippAi.Core.BonusQuestion, EHonda.KicktippAi.Core.BonusPrediction, string, string, double, string, System.Collections.Generic.IEnumerable<string>, bool, System.Threading.CancellationToken)
SaveBonusPredictionAsync()
GetBonusPredictionAsync(string, string, string, System.Threading.CancellationToken)
GetBonusPredictionAsync()
GetBonusPredictionByTextAsync(string, string, string, System.Threading.CancellationToken)
GetBonusPredictionByTextAsync()
GetBonusPredictionMetadataByTextAsync(string, string, string, System.Threading.CancellationToken)
GetBonusPredictionMetadataByTextAsync()
GetAllBonusPredictionsAsync(string, string, System.Threading.CancellationToken)
GetAllBonusPredictionsAsync()
HasBonusPredictionAsync(string, string, string, System.Threading.CancellationToken)
HasBonusPredictionAsync()
StoreMatchAsync()
ConvertToTimestamp(NodaTime.ZonedDateTime)
ConvertFromTimestamp(Google.Cloud.Firestore.Timestamp)
GetMatchRepredictionIndexAsync(EHonda.KicktippAi.Core.Match, string, string, System.Threading.CancellationToken)
GetMatchRepredictionIndexAsync()
GetCancelledMatchPredictionAsync(string, string, string, string, System.Threading.CancellationToken)
GetCancelledMatchPredictionAsync()
GetCancelledMatchPredictionMetadataAsync(string, string, string, string, System.Threading.CancellationToken)
GetCancelledMatchPredictionMetadataAsync()
GetCancelledMatchRepredictionIndexAsync(string, string, string, string, System.Threading.CancellationToken)
GetCancelledMatchRepredictionIndexAsync()
GetBonusRepredictionIndexAsync(string, string, string, System.Threading.CancellationToken)
GetBonusRepredictionIndexAsync()
SaveRepredictionAsync(EHonda.KicktippAi.Core.Match, EHonda.KicktippAi.Core.Prediction, string, string, double, string, System.Collections.Generic.IEnumerable<string>, int, System.Threading.CancellationToken)
SaveRepredictionAsync()
SaveBonusRepredictionAsync(EHonda.KicktippAi.Core.BonusQuestion, EHonda.KicktippAi.Core.BonusPrediction, string, string, double, string, System.Collections.Generic.IEnumerable<string>, int, System.Threading.CancellationToken)
SaveBonusRepredictionAsync()
GetMatchPredictionCostsByRepredictionIndexAsync(string, string, System.Collections.Generic.List<int>, System.Threading.CancellationToken)
GetMatchPredictionCostsByRepredictionIndexAsync()
GetBonusPredictionCostsByRepredictionIndexAsync(string, string, System.Threading.CancellationToken)
GetBonusPredictionCostsByRepredictionIndexAsync()
GetAvailableMatchdaysAsync()
GetAvailableModelsAsync()
GetAvailableModelConfigsAsync()
AddModelConfigIfValid(System.Collections.Generic.Dictionary<string, EHonda.KicktippAi.Core.PredictionModelConfig>, FirebaseAdapter.Models.FirestoreMatchPrediction)
AddModelConfigIfValid(System.Collections.Generic.Dictionary<string, EHonda.KicktippAi.Core.PredictionModelConfig>, FirebaseAdapter.Models.FirestoreBonusPrediction)
AddModelConfigIfValid(System.Collections.Generic.Dictionary<string, EHonda.KicktippAi.Core.PredictionModelConfig>, string, string)
GetAvailableCommunityContextsAsync()
SerializeJustification(EHonda.KicktippAi.Core.PredictionJustification)
HasJustificationContent(EHonda.KicktippAi.Core.PredictionJustification)
HasSourceContent(EHonda.KicktippAi.Core.PredictionJustificationContextSource)
DeserializeJustification(string)
ToStoredContextSource(EHonda.KicktippAi.Core.PredictionJustificationContextSource)
ToDomainContextSource(FirebaseAdapter.FirebasePredictionRepository.StoredContextSource)