< Summary

Information
Class: FirebaseAdapter.FirebasePredictionRepository
Assembly: FirebaseAdapter
File(s): /home/runner/work/KicktippAi/KicktippAi/src/FirebaseAdapter/FirebasePredictionRepository.cs
Line coverage
84%
Covered lines: 493
Uncovered lines: 93
Coverable lines: 586
Total lines: 1042
Line coverage: 84.1%
Branch coverage
76%
Covered branches: 155
Total branches: 202
Branch coverage: 76.7%
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%44100%
SavePredictionAsync()100%8893.1%
GetPredictionAsync()100%11100%
GetPredictionAsync()100%2281.82%
GetPredictionMetadataAsync()50%6678.26%
GetMatchDayAsync()100%4475%
GetMatchDayWithPredictionsAsync()100%2272.73%
GetAllPredictionsAsync()100%2276.92%
HasPredictionAsync()100%1171.43%
SaveBonusPredictionAsync()100%121292.73%
GetBonusPredictionAsync()50%2264.29%
GetBonusPredictionByTextAsync()100%2285%
GetBonusPredictionMetadataByTextAsync()50%6676.19%
GetAllBonusPredictionsAsync()100%2281.25%
HasBonusPredictionAsync()100%1172.73%
StoreMatchAsync()100%2281.82%
ConvertToTimestamp(...)100%11100%
ConvertFromTimestamp(...)100%11100%
GetMatchRepredictionIndexAsync()100%2278.95%
GetBonusRepredictionIndexAsync()100%2276.47%
SaveRepredictionAsync()100%1188.24%
SaveBonusRepredictionAsync()100%4487.88%
GetMatchPredictionCostsByRepredictionIndexAsync()100%111081.82%
GetBonusPredictionCostsByRepredictionIndexAsync()100%6680%
SerializeJustification(...)69.44%3636100%
HasJustificationContent(...)81.82%2222100%
HasSourceContent(...)33.33%66100%
DeserializeJustification(...)62.5%1694056.76%
ToStoredContextSource(...)100%88100%
ToDomainContextSource(...)50%88100%
get_KeyReasoning()100%11100%
set_KeyReasoning(...)100%11100%
get_ContextSources()100%11100%
set_ContextSources(...)100%11100%
get_Uncertainties()100%11100%
set_Uncertainties(...)100%11100%
get_MostValuable()100%11100%
set_MostValuable(...)100%11100%
get_LeastValuable()100%11100%
set_LeastValuable(...)100%11100%
get_DocumentName()100%11100%
set_DocumentName(...)100%11100%
get_Details()100%11100%
set_Details(...)100%11100%

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
 131    public FirebasePredictionRepository(FirestoreDb firestoreDb, ILogger<FirebasePredictionRepository> logger)
 32    {
 133        _firestoreDb = firestoreDb ?? throw new ArgumentNullException(nameof(firestoreDb));
 134        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
 35
 36        // Use unified collection names (no longer community-specific)
 137        _predictionsCollection = "match-predictions";
 138        _matchesCollection = "matches";
 139        _bonusPredictionsCollection = "bonus-predictions";
 140        _competition = "bundesliga-2025-26"; // Remove community suffix
 41
 142        _logger.LogInformation("Firebase repository initialized");
 143    }
 44
 45    public async Task SavePredictionAsync(Match match, Prediction prediction, string model, string tokenUsage, double co
 46    {
 47        try
 48        {
 149            var now = Timestamp.GetCurrentTimestamp();
 50
 51            // Check if a prediction already exists for this match, model, and community context
 52            // Order by repredictionIndex descending to get the latest version for updating
 153            var query = _firestoreDb.Collection(_predictionsCollection)
 154                .WhereEqualTo("homeTeam", match.HomeTeam)
 155                .WhereEqualTo("awayTeam", match.AwayTeam)
 156                .WhereEqualTo("startsAt", ConvertToTimestamp(match.StartsAt))
 157                .WhereEqualTo("competition", _competition)
 158                .WhereEqualTo("model", model)
 159                .WhereEqualTo("communityContext", communityContext)
 160                .OrderByDescending("repredictionIndex")
 161                .Limit(1);
 62
 163            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 64
 65            DocumentReference docRef;
 166            bool isUpdate = false;
 167            Timestamp? existingCreatedAt = null;
 168            int repredictionIndex = 0;
 69
 170            if (snapshot.Documents.Count > 0)
 71            {
 72                // Update existing document (latest reprediction)
 173                var existingDoc = snapshot.Documents.First();
 174                docRef = existingDoc.Reference;
 175                isUpdate = true;
 76
 77                // Preserve the original values
 178                var existingData = existingDoc.ConvertTo<FirestoreMatchPrediction>();
 179                existingCreatedAt = existingData.CreatedAt;
 180                repredictionIndex = existingData.RepredictionIndex; // Keep same reprediction index for override
 81
 182                _logger.LogDebug("Updating existing prediction for match {HomeTeam} vs {AwayTeam} (document: {DocumentId
 183                    match.HomeTeam, match.AwayTeam, existingDoc.Id, repredictionIndex);
 84            }
 85            else
 86            {
 87                // Create new document
 188                var documentId = Guid.NewGuid().ToString();
 189                docRef = _firestoreDb.Collection(_predictionsCollection).Document(documentId);
 190                repredictionIndex = 0; // First prediction
 91
 192                _logger.LogDebug("Creating new prediction for match {HomeTeam} vs {AwayTeam} (document: {DocumentId}, re
 193                    match.HomeTeam, match.AwayTeam, documentId, repredictionIndex);
 94            }
 95
 196            var firestorePrediction = new FirestoreMatchPrediction
 197            {
 198                Id = docRef.Id,
 199                HomeTeam = match.HomeTeam,
 1100                AwayTeam = match.AwayTeam,
 1101                StartsAt = ConvertToTimestamp(match.StartsAt),
 1102                Matchday = match.Matchday,
 1103                HomeGoals = prediction.HomeGoals,
 1104                AwayGoals = prediction.AwayGoals,
 1105                Justification = SerializeJustification(prediction.Justification),
 1106                UpdatedAt = now,
 1107                Competition = _competition,
 1108                Model = model,
 1109                TokenUsage = tokenUsage,
 1110                Cost = cost,
 1111                CommunityContext = communityContext,
 1112                ContextDocumentNames = contextDocumentNames.ToArray(),
 1113                RepredictionIndex = repredictionIndex
 1114            };
 115
 116            // Set CreatedAt: preserve existing value for updates unless overrideCreatedAt is explicitly requested
 1117            firestorePrediction.CreatedAt = (overrideCreatedAt || existingCreatedAt == null) ? now : existingCreatedAt.V
 118
 1119            await docRef.SetAsync(firestorePrediction, cancellationToken: cancellationToken);
 120
 1121            var action = isUpdate ? "Updated" : "Saved";
 1122            _logger.LogInformation("{Action} prediction for match {HomeTeam} vs {AwayTeam} on matchday {Matchday} (repre
 1123                action, match.HomeTeam, match.AwayTeam, match.Matchday, repredictionIndex);
 1124        }
 0125        catch (Exception ex)
 126        {
 0127            _logger.LogError(ex, "Failed to save prediction for match {HomeTeam} vs {AwayTeam}",
 0128                match.HomeTeam, match.AwayTeam);
 0129            throw;
 130        }
 1131    }
 132
 133    public async Task<Prediction?> GetPredictionAsync(Match match, string model, string communityContext, CancellationTo
 134    {
 1135        return await GetPredictionAsync(match.HomeTeam, match.AwayTeam, match.StartsAt, model, communityContext, cancell
 1136    }
 137
 138    public async Task<Prediction?> GetPredictionAsync(string homeTeam, string awayTeam, ZonedDateTime startsAt, string m
 139    {
 140        try
 141        {
 142            // Query by match characteristics, model, community context, and competition
 143            // Order by repredictionIndex descending to get the latest version
 1144            var query = _firestoreDb.Collection(_predictionsCollection)
 1145                .WhereEqualTo("homeTeam", homeTeam)
 1146                .WhereEqualTo("awayTeam", awayTeam)
 1147                .WhereEqualTo("startsAt", ConvertToTimestamp(startsAt))
 1148                .WhereEqualTo("competition", _competition)
 1149                .WhereEqualTo("model", model)
 1150                .WhereEqualTo("communityContext", communityContext)
 1151                .OrderByDescending("repredictionIndex")
 1152                .Limit(1);
 153
 1154            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 155
 1156            if (snapshot.Documents.Count == 0)
 157            {
 1158                return null;
 159            }
 160
 1161            var firestorePrediction = snapshot.Documents.First().ConvertTo<FirestoreMatchPrediction>();
 1162            return new Prediction(
 1163                firestorePrediction.HomeGoals,
 1164                firestorePrediction.AwayGoals,
 1165                DeserializeJustification(firestorePrediction.Justification));
 166        }
 0167        catch (Exception ex)
 168        {
 0169            _logger.LogError(ex, "Failed to get prediction for match {HomeTeam} vs {AwayTeam} using model {Model} and co
 0170                homeTeam, awayTeam, model, communityContext);
 0171            throw;
 172        }
 1173    }
 174
 175    public async Task<PredictionMetadata?> GetPredictionMetadataAsync(Match match, string model, string communityContext
 176    {
 177        try
 178        {
 179            // Query by match characteristics, model, community context, and competition
 1180            var query = _firestoreDb.Collection(_predictionsCollection)
 1181                .WhereEqualTo("homeTeam", match.HomeTeam)
 1182                .WhereEqualTo("awayTeam", match.AwayTeam)
 1183                .WhereEqualTo("startsAt", ConvertToTimestamp(match.StartsAt))
 1184                .WhereEqualTo("competition", _competition)
 1185                .WhereEqualTo("model", model)
 1186                .WhereEqualTo("communityContext", communityContext);
 187
 1188            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 189
 1190            if (snapshot.Documents.Count == 0)
 191            {
 0192                return null;
 193            }
 194
 1195            var firestorePrediction = snapshot.Documents.First().ConvertTo<FirestoreMatchPrediction>();
 1196            var prediction = new Prediction(
 1197                firestorePrediction.HomeGoals,
 1198                firestorePrediction.AwayGoals,
 1199                DeserializeJustification(firestorePrediction.Justification));
 1200            var createdAt = firestorePrediction.CreatedAt.ToDateTimeOffset();
 1201            var contextDocumentNames = firestorePrediction.ContextDocumentNames?.ToList() ?? new List<string>();
 202
 1203            return new PredictionMetadata(prediction, createdAt, contextDocumentNames);
 204        }
 0205        catch (Exception ex)
 206        {
 0207            _logger.LogError(ex, "Failed to get prediction metadata for match {HomeTeam} vs {AwayTeam} using model {Mode
 0208                match.HomeTeam, match.AwayTeam, model, communityContext);
 0209            throw;
 210        }
 1211    }
 212
 213    public async Task<IReadOnlyList<Match>> GetMatchDayAsync(int matchDay, CancellationToken cancellationToken = default
 214    {
 215        try
 216        {
 1217            var query = _firestoreDb.Collection(_matchesCollection)
 1218                .WhereEqualTo("competition", _competition)
 1219                .WhereEqualTo("matchday", matchDay)
 1220                .OrderBy("startsAt");
 221
 1222            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 223
 1224            var matches = snapshot.Documents
 1225                .Select(doc => doc.ConvertTo<FirestoreMatch>())
 1226                .Select(fm => new Match(
 1227                    fm.HomeTeam,
 1228                    fm.AwayTeam,
 1229                    ConvertFromTimestamp(fm.StartsAt),
 1230                    fm.Matchday,
 1231                    fm.IsCancelled))
 1232                .ToList();
 233
 1234            return matches.AsReadOnly();
 235        }
 0236        catch (Exception ex)
 237        {
 0238            _logger.LogError(ex, "Failed to get matches for matchday {Matchday}", matchDay);
 0239            throw;
 240        }
 1241    }
 242
 243    public async Task<IReadOnlyList<MatchPrediction>> GetMatchDayWithPredictionsAsync(int matchDay, string model, string
 244    {
 245        try
 246        {
 247            // Get all matches for the matchday
 1248            var matches = await GetMatchDayAsync(matchDay, cancellationToken);
 249
 250            // Get predictions for all matches using the specified model and community context
 1251            var matchPredictions = new List<MatchPrediction>();
 252
 1253            foreach (var match in matches)
 254            {
 1255                var prediction = await GetPredictionAsync(match, model, communityContext, cancellationToken);
 1256                matchPredictions.Add(new MatchPrediction(match, prediction));
 1257            }
 258
 1259            return matchPredictions.AsReadOnly();
 260        }
 0261        catch (Exception ex)
 262        {
 0263            _logger.LogError(ex, "Failed to get matches with predictions for matchday {Matchday} using model {Model} and
 0264            throw;
 265        }
 1266    }
 267
 268    public async Task<IReadOnlyList<MatchPrediction>> GetAllPredictionsAsync(string model, string communityContext, Canc
 269    {
 270        try
 271        {
 1272            var query = _firestoreDb.Collection(_predictionsCollection)
 1273                .WhereEqualTo("competition", _competition)
 1274                .WhereEqualTo("model", model)
 1275                .WhereEqualTo("communityContext", communityContext)
 1276                .OrderBy("matchday");
 277
 1278            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 279
 1280            var matchPredictions = snapshot.Documents
 1281                .Select(doc => doc.ConvertTo<FirestoreMatchPrediction>())
 1282                .Select(fp => new MatchPrediction(
 1283                    new Match(fp.HomeTeam, fp.AwayTeam, ConvertFromTimestamp(fp.StartsAt), fp.Matchday),
 1284                    new Prediction(
 1285                        fp.HomeGoals,
 1286                        fp.AwayGoals,
 1287                        DeserializeJustification(fp.Justification))))
 1288                .ToList();
 289
 1290            return matchPredictions.AsReadOnly();
 291        }
 0292        catch (Exception ex)
 293        {
 0294            _logger.LogError(ex, "Failed to get all predictions for model {Model} and community context {CommunityContex
 0295            throw;
 296        }
 1297    }
 298
 299    public async Task<bool> HasPredictionAsync(Match match, string model, string communityContext, CancellationToken can
 300    {
 301        try
 302        {
 303            // Query by match characteristics, model, and community context instead of using deterministic ID
 1304            var query = _firestoreDb.Collection(_predictionsCollection)
 1305                .WhereEqualTo("homeTeam", match.HomeTeam)
 1306                .WhereEqualTo("awayTeam", match.AwayTeam)
 1307                .WhereEqualTo("startsAt", ConvertToTimestamp(match.StartsAt))
 1308                .WhereEqualTo("competition", _competition)
 1309                .WhereEqualTo("model", model)
 1310                .WhereEqualTo("communityContext", communityContext);
 311
 1312            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 1313            return snapshot.Documents.Count > 0;
 314        }
 0315        catch (Exception ex)
 316        {
 0317            _logger.LogError(ex, "Failed to check if prediction exists for match {HomeTeam} vs {AwayTeam} using model {M
 0318                match.HomeTeam, match.AwayTeam, model, communityContext);
 0319            throw;
 320        }
 1321    }
 322
 323    public async Task SaveBonusPredictionAsync(BonusQuestion bonusQuestion, BonusPrediction bonusPrediction, string mode
 324    {
 325        try
 326        {
 1327            var now = Timestamp.GetCurrentTimestamp();
 328
 329            // Check if a prediction already exists for this question, model, and community context
 330            // Order by repredictionIndex descending to get the latest version for updating
 1331            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 1332                .WhereEqualTo("questionText", bonusQuestion.Text)
 1333                .WhereEqualTo("competition", _competition)
 1334                .WhereEqualTo("model", model)
 1335                .WhereEqualTo("communityContext", communityContext)
 1336                .OrderByDescending("repredictionIndex")
 1337                .Limit(1);
 338
 1339            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 340
 341            DocumentReference docRef;
 1342            bool isUpdate = false;
 1343            Timestamp? existingCreatedAt = null;
 1344            int repredictionIndex = 0;
 345
 1346            if (snapshot.Documents.Count > 0)
 347            {
 348                // Update existing document (latest reprediction)
 1349                var existingDoc = snapshot.Documents.First();
 1350                docRef = existingDoc.Reference;
 1351                isUpdate = true;
 352
 353                // Preserve the original values
 1354                var existingData = existingDoc.ConvertTo<FirestoreBonusPrediction>();
 1355                existingCreatedAt = existingData.CreatedAt;
 1356                repredictionIndex = existingData.RepredictionIndex; // Keep same reprediction index for override
 357
 1358                _logger.LogDebug("Updating existing bonus prediction for question '{QuestionText}' (document: {DocumentI
 1359                    bonusQuestion.Text, existingDoc.Id, repredictionIndex);
 360            }
 361            else
 362            {
 363                // Create new document
 1364                var documentId = Guid.NewGuid().ToString();
 1365                docRef = _firestoreDb.Collection(_bonusPredictionsCollection).Document(documentId);
 1366                repredictionIndex = 0; // First prediction
 367
 1368                _logger.LogDebug("Creating new bonus prediction for question '{QuestionText}' (document: {DocumentId}, r
 1369                    bonusQuestion.Text, documentId, repredictionIndex);
 370            }
 371
 372            // Extract selected option texts for observability
 1373            var optionTextsLookup = bonusQuestion.Options.ToDictionary(o => o.Id, o => o.Text);
 1374            var selectedOptionTexts = bonusPrediction.SelectedOptionIds
 1375                .Select(id => optionTextsLookup.TryGetValue(id, out var text) ? text : $"Unknown option: {id}")
 1376                .ToArray();
 377
 1378            var firestoreBonusPrediction = new FirestoreBonusPrediction
 1379            {
 1380                Id = docRef.Id,
 1381                QuestionText = bonusQuestion.Text,
 1382                SelectedOptionIds = bonusPrediction.SelectedOptionIds.ToArray(),
 1383                SelectedOptionTexts = selectedOptionTexts,
 1384                UpdatedAt = now,
 1385                Competition = _competition,
 1386                Model = model,
 1387                TokenUsage = tokenUsage,
 1388                Cost = cost,
 1389                CommunityContext = communityContext,
 1390                ContextDocumentNames = contextDocumentNames.ToArray(),
 1391                RepredictionIndex = repredictionIndex
 1392            };
 393
 394            // Set CreatedAt: preserve existing value for updates unless overrideCreatedAt is explicitly requested
 1395            firestoreBonusPrediction.CreatedAt = (overrideCreatedAt || existingCreatedAt == null) ? now : existingCreate
 396
 1397            await docRef.SetAsync(firestoreBonusPrediction, cancellationToken: cancellationToken);
 398
 1399            var action = isUpdate ? "Updated" : "Saved";
 1400            _logger.LogDebug("{Action} bonus prediction for question '{QuestionText}' with selections: {SelectedOptions}
 1401                action, bonusQuestion.Text, string.Join(", ", selectedOptionTexts), repredictionIndex);
 1402        }
 0403        catch (Exception ex)
 404        {
 0405            _logger.LogError(ex, "Failed to save bonus prediction for question: {QuestionText}",
 0406                bonusQuestion.Text);
 0407            throw;
 408        }
 1409    }
 410
 411    public async Task<BonusPrediction?> GetBonusPredictionAsync(string questionId, string model, string communityContext
 412    {
 413        try
 414        {
 415            // Query by questionId, model, community context, and competition instead of using direct document lookup
 1416            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 1417                .WhereEqualTo("questionId", questionId)
 1418                .WhereEqualTo("competition", _competition)
 1419                .WhereEqualTo("model", model)
 1420                .WhereEqualTo("communityContext", communityContext);
 421
 1422            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 423
 1424            if (snapshot.Documents.Count == 0)
 425            {
 1426                return null;
 427            }
 428
 0429            var firestoreBonusPrediction = snapshot.Documents.First().ConvertTo<FirestoreBonusPrediction>();
 0430            return new BonusPrediction(firestoreBonusPrediction.SelectedOptionIds.ToList());
 431        }
 0432        catch (Exception ex)
 433        {
 0434            _logger.LogError(ex, "Failed to get bonus prediction for question {QuestionId} using model {Model} and commu
 0435            throw;
 436        }
 1437    }
 438
 439    public async Task<BonusPrediction?> GetBonusPredictionByTextAsync(string questionText, string model, string communit
 440    {
 441        try
 442        {
 443            // Query by questionText, model, and community context
 444            // Order by repredictionIndex descending to get the latest version
 1445            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 1446                .WhereEqualTo("questionText", questionText)
 1447                .WhereEqualTo("competition", _competition)
 1448                .WhereEqualTo("model", model)
 1449                .WhereEqualTo("communityContext", communityContext)
 1450                .OrderByDescending("repredictionIndex")
 1451                .Limit(1);
 452
 1453            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 454
 1455            if (snapshot.Documents.Count == 0)
 456            {
 1457                _logger.LogDebug("No bonus prediction found for question text: {QuestionText} with model: {Model} and co
 1458                return null;
 459            }
 460
 1461            var firestoreBonusPrediction = snapshot.Documents.First().ConvertTo<FirestoreBonusPrediction>();
 1462            var bonusPrediction = new BonusPrediction(firestoreBonusPrediction.SelectedOptionIds.ToList());
 463
 1464            _logger.LogDebug("Found bonus prediction for question text: {QuestionText} with model: {Model} and community
 1465                questionText, model, communityContext, firestoreBonusPrediction.RepredictionIndex);
 466
 1467            return bonusPrediction;
 468        }
 0469        catch (Exception ex)
 470        {
 0471            _logger.LogError(ex, "Failed to retrieve bonus prediction by text: {QuestionText} with model: {Model} and co
 0472            throw;
 473        }
 1474    }
 475
 476    public async Task<BonusPredictionMetadata?> GetBonusPredictionMetadataByTextAsync(string questionText, string model,
 477    {
 478        try
 479        {
 480            // Query by questionText, model, and community context
 1481            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 1482                .WhereEqualTo("questionText", questionText)
 1483                .WhereEqualTo("competition", _competition)
 1484                .WhereEqualTo("model", model)
 1485                .WhereEqualTo("communityContext", communityContext)
 1486                .Limit(1);
 487
 1488            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 489
 1490            if (snapshot.Documents.Count == 0)
 491            {
 0492                _logger.LogDebug("No bonus prediction metadata found for question text: {QuestionText} with model: {Mode
 0493                return null;
 494            }
 495
 1496            var firestoreBonusPrediction = snapshot.Documents.First().ConvertTo<FirestoreBonusPrediction>();
 1497            var bonusPrediction = new BonusPrediction(firestoreBonusPrediction.SelectedOptionIds.ToList());
 1498            var createdAt = firestoreBonusPrediction.CreatedAt.ToDateTimeOffset();
 1499            var contextDocumentNames = firestoreBonusPrediction.ContextDocumentNames?.ToList() ?? new List<string>();
 500
 1501            _logger.LogDebug("Found bonus prediction metadata for question text: {QuestionText} with model: {Model} and 
 1502                questionText, model, communityContext);
 503
 1504            return new BonusPredictionMetadata(bonusPrediction, createdAt, contextDocumentNames);
 505        }
 0506        catch (Exception ex)
 507        {
 0508            _logger.LogError(ex, "Failed to retrieve bonus prediction metadata by text: {QuestionText} with model: {Mode
 0509            throw;
 510        }
 1511    }
 512
 513    public async Task<IReadOnlyList<BonusPrediction>> GetAllBonusPredictionsAsync(string model, string communityContext,
 514    {
 515        try
 516        {
 1517            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 1518                .WhereEqualTo("competition", _competition)
 1519                .WhereEqualTo("model", model)
 1520                .WhereEqualTo("communityContext", communityContext)
 1521                .OrderBy("createdAt");
 522
 1523            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 524
 1525            var bonusPredictions = new List<BonusPrediction>();
 1526            foreach (var document in snapshot.Documents)
 527            {
 1528                var firestoreBonusPrediction = document.ConvertTo<FirestoreBonusPrediction>();
 1529                bonusPredictions.Add(new BonusPrediction(
 1530                    firestoreBonusPrediction.SelectedOptionIds.ToList()));
 531            }
 532
 1533            return bonusPredictions.AsReadOnly();
 534        }
 0535        catch (Exception ex)
 536        {
 0537            _logger.LogError(ex, "Failed to get all bonus predictions for model {Model} and community context {Community
 0538            throw;
 539        }
 1540    }
 541
 542    public async Task<bool> HasBonusPredictionAsync(string questionId, string model, string communityContext, Cancellati
 543    {
 544        try
 545        {
 546            // Query by questionId, model, and community context instead of using direct document lookup
 1547            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 1548                .WhereEqualTo("questionId", questionId)
 1549                .WhereEqualTo("competition", _competition)
 1550                .WhereEqualTo("model", model)
 1551                .WhereEqualTo("communityContext", communityContext);
 552
 1553            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 1554            return snapshot.Documents.Count > 0;
 555        }
 0556        catch (Exception ex)
 557        {
 0558            _logger.LogError(ex, "Failed to check if bonus prediction exists for question {QuestionId} using model {Mode
 0559            throw;
 560        }
 1561    }
 562
 563    /// <summary>
 564    /// Stores a match in the matches collection for matchday management.
 565    /// This is typically called when importing match schedules.
 566    /// </summary>
 567    public async Task StoreMatchAsync(Match match, CancellationToken cancellationToken = default)
 568    {
 569        try
 570        {
 1571            var documentId = Guid.NewGuid().ToString();
 572
 1573            var firestoreMatch = new FirestoreMatch
 1574            {
 1575                Id = documentId,
 1576                HomeTeam = match.HomeTeam,
 1577                AwayTeam = match.AwayTeam,
 1578                StartsAt = ConvertToTimestamp(match.StartsAt),
 1579                Matchday = match.Matchday,
 1580                Competition = _competition,
 1581                IsCancelled = match.IsCancelled
 1582            };
 583
 1584            await _firestoreDb.Collection(_matchesCollection)
 1585                .Document(documentId)
 1586                .SetAsync(firestoreMatch, cancellationToken: cancellationToken);
 587
 1588            _logger.LogDebug("Stored match {HomeTeam} vs {AwayTeam} for matchday {Matchday}{Cancelled}",
 1589                match.HomeTeam, match.AwayTeam, match.Matchday, match.IsCancelled ? " (CANCELLED)" : "");
 1590        }
 0591        catch (Exception ex)
 592        {
 0593            _logger.LogError(ex, "Failed to store match {HomeTeam} vs {AwayTeam}",
 0594                match.HomeTeam, match.AwayTeam);
 0595            throw;
 596        }
 1597    }
 598
 599    private static Timestamp ConvertToTimestamp(ZonedDateTime zonedDateTime)
 600    {
 1601        var instant = zonedDateTime.ToInstant();
 1602        return Timestamp.FromDateTimeOffset(instant.ToDateTimeOffset());
 603    }
 604
 605    private static ZonedDateTime ConvertFromTimestamp(Timestamp timestamp)
 606    {
 1607        var dateTimeOffset = timestamp.ToDateTimeOffset();
 1608        var instant = Instant.FromDateTimeOffset(dateTimeOffset);
 1609        return instant.InUtc();
 610    }
 611
 612    public async Task<int> GetMatchRepredictionIndexAsync(Match match, string model, string communityContext, Cancellati
 613    {
 614        try
 615        {
 616            // Query by match characteristics, model, community context, and competition
 617            // Order by repredictionIndex descending to get the latest version
 1618            var query = _firestoreDb.Collection(_predictionsCollection)
 1619                .WhereEqualTo("homeTeam", match.HomeTeam)
 1620                .WhereEqualTo("awayTeam", match.AwayTeam)
 1621                .WhereEqualTo("startsAt", ConvertToTimestamp(match.StartsAt))
 1622                .WhereEqualTo("competition", _competition)
 1623                .WhereEqualTo("model", model)
 1624                .WhereEqualTo("communityContext", communityContext)
 1625                .OrderByDescending("repredictionIndex")
 1626                .Limit(1);
 627
 1628            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 629
 1630            if (snapshot.Documents.Count == 0)
 631            {
 1632                return -1; // No prediction exists
 633            }
 634
 1635            var firestorePrediction = snapshot.Documents.First().ConvertTo<FirestoreMatchPrediction>();
 1636            return firestorePrediction.RepredictionIndex;
 637        }
 0638        catch (Exception ex)
 639        {
 0640            _logger.LogError(ex, "Failed to get reprediction index for match {HomeTeam} vs {AwayTeam} using model {Model
 0641                match.HomeTeam, match.AwayTeam, model, communityContext);
 0642            throw;
 643        }
 1644    }
 645
 646    public async Task<int> GetBonusRepredictionIndexAsync(string questionText, string model, string communityContext, Ca
 647    {
 648        try
 649        {
 650            // Query by question text, model, community context, and competition
 651            // Order by repredictionIndex descending to get the latest version
 1652            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 1653                .WhereEqualTo("questionText", questionText)
 1654                .WhereEqualTo("competition", _competition)
 1655                .WhereEqualTo("model", model)
 1656                .WhereEqualTo("communityContext", communityContext)
 1657                .OrderByDescending("repredictionIndex")
 1658                .Limit(1);
 659
 1660            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 661
 1662            if (snapshot.Documents.Count == 0)
 663            {
 1664                return -1; // No prediction exists
 665            }
 666
 1667            var firestorePrediction = snapshot.Documents.First().ConvertTo<FirestoreBonusPrediction>();
 1668            return firestorePrediction.RepredictionIndex;
 669        }
 0670        catch (Exception ex)
 671        {
 0672            _logger.LogError(ex, "Failed to get reprediction index for bonus question '{QuestionText}' using model {Mode
 0673                questionText, model, communityContext);
 0674            throw;
 675        }
 1676    }
 677
 678    public async Task SaveRepredictionAsync(Match match, Prediction prediction, string model, string tokenUsage, double 
 679    {
 680        try
 681        {
 1682            var now = Timestamp.GetCurrentTimestamp();
 683
 684            // Create new document for this reprediction
 1685            var documentId = Guid.NewGuid().ToString();
 1686            var docRef = _firestoreDb.Collection(_predictionsCollection).Document(documentId);
 687
 1688            _logger.LogDebug("Creating reprediction for match {HomeTeam} vs {AwayTeam} (document: {DocumentId}, repredic
 1689                match.HomeTeam, match.AwayTeam, documentId, repredictionIndex);
 690
 1691            var firestorePrediction = new FirestoreMatchPrediction
 1692            {
 1693                Id = docRef.Id,
 1694                HomeTeam = match.HomeTeam,
 1695                AwayTeam = match.AwayTeam,
 1696                StartsAt = ConvertToTimestamp(match.StartsAt),
 1697                Matchday = match.Matchday,
 1698                HomeGoals = prediction.HomeGoals,
 1699                AwayGoals = prediction.AwayGoals,
 1700                Justification = SerializeJustification(prediction.Justification),
 1701                CreatedAt = now,
 1702                UpdatedAt = now,
 1703                Competition = _competition,
 1704                Model = model,
 1705                TokenUsage = tokenUsage,
 1706                Cost = cost,
 1707                CommunityContext = communityContext,
 1708                ContextDocumentNames = contextDocumentNames.ToArray(),
 1709                RepredictionIndex = repredictionIndex
 1710            };
 711
 1712            await docRef.SetAsync(firestorePrediction, cancellationToken: cancellationToken);
 713
 1714            _logger.LogInformation("Saved reprediction for match {HomeTeam} vs {AwayTeam} on matchday {Matchday} (repred
 1715                match.HomeTeam, match.AwayTeam, match.Matchday, repredictionIndex);
 1716        }
 0717        catch (Exception ex)
 718        {
 0719            _logger.LogError(ex, "Failed to save reprediction for match {HomeTeam} vs {AwayTeam}",
 0720                match.HomeTeam, match.AwayTeam);
 0721            throw;
 722        }
 1723    }
 724
 725    public async Task SaveBonusRepredictionAsync(BonusQuestion bonusQuestion, BonusPrediction bonusPrediction, string mo
 726    {
 727        try
 728        {
 1729            var now = Timestamp.GetCurrentTimestamp();
 730
 731            // Create new document for this reprediction
 1732            var documentId = Guid.NewGuid().ToString();
 1733            var docRef = _firestoreDb.Collection(_bonusPredictionsCollection).Document(documentId);
 734
 1735            _logger.LogDebug("Creating bonus reprediction for question '{QuestionText}' (document: {DocumentId}, repredi
 1736                bonusQuestion.Text, documentId, repredictionIndex);
 737
 738            // Extract selected option texts for observability
 1739            var optionTextsLookup = bonusQuestion.Options.ToDictionary(o => o.Id, o => o.Text);
 1740            var selectedOptionTexts = bonusPrediction.SelectedOptionIds
 1741                .Select(id => optionTextsLookup.TryGetValue(id, out var text) ? text : $"Unknown option: {id}")
 1742                .ToArray();
 743
 1744            var firestoreBonusPrediction = new FirestoreBonusPrediction
 1745            {
 1746                Id = docRef.Id,
 1747                QuestionText = bonusQuestion.Text,
 1748                SelectedOptionIds = bonusPrediction.SelectedOptionIds.ToArray(),
 1749                SelectedOptionTexts = selectedOptionTexts,
 1750                CreatedAt = now,
 1751                UpdatedAt = now,
 1752                Competition = _competition,
 1753                Model = model,
 1754                TokenUsage = tokenUsage,
 1755                Cost = cost,
 1756                CommunityContext = communityContext,
 1757                ContextDocumentNames = contextDocumentNames.ToArray(),
 1758                RepredictionIndex = repredictionIndex
 1759            };
 760
 1761            await docRef.SetAsync(firestoreBonusPrediction, cancellationToken: cancellationToken);
 762
 1763            _logger.LogInformation("Saved bonus reprediction for question '{QuestionText}' (reprediction index: {Repredi
 1764                bonusQuestion.Text, repredictionIndex);
 1765        }
 0766        catch (Exception ex)
 767        {
 0768            _logger.LogError(ex, "Failed to save bonus reprediction for question: {QuestionText}",
 0769                bonusQuestion.Text);
 0770            throw;
 771        }
 1772    }
 773
 774    /// <summary>
 775    /// Get match prediction costs and counts grouped by reprediction index for cost analysis.
 776    /// Used specifically by the cost command to include all repredictions.
 777    /// </summary>
 778    public async Task<Dictionary<int, (double cost, int count)>> GetMatchPredictionCostsByRepredictionIndexAsync(
 779        string model,
 780        string communityContext,
 781        List<int>? matchdays = null,
 782        CancellationToken cancellationToken = default)
 783    {
 784        try
 785        {
 1786            var costsByIndex = new Dictionary<int, (double cost, int count)>();
 787
 788            // Query for match predictions with cost data
 1789            var query = _firestoreDb.Collection(_predictionsCollection)
 1790                .WhereEqualTo("competition", _competition)
 1791                .WhereEqualTo("model", model)
 1792                .WhereEqualTo("communityContext", communityContext);
 793
 794            // Add matchday filter if specified
 1795            if (matchdays?.Count > 0)
 796            {
 1797                query = query.WhereIn("matchday", matchdays.Cast<object>().ToArray());
 798            }
 799
 1800            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 801
 1802            foreach (var doc in snapshot.Documents)
 803            {
 1804                if (doc.Exists)
 805                {
 1806                    var prediction = doc.ConvertTo<FirestoreMatchPrediction>();
 1807                    var repredictionIndex = prediction.RepredictionIndex;
 808
 1809                    if (!costsByIndex.ContainsKey(repredictionIndex))
 810                    {
 1811                        costsByIndex[repredictionIndex] = (0.0, 0);
 812                    }
 813
 1814                    var (currentCost, currentCount) = costsByIndex[repredictionIndex];
 1815                    costsByIndex[repredictionIndex] = (currentCost + prediction.Cost, currentCount + 1);
 816                }
 817            }
 818
 1819            return costsByIndex;
 820        }
 0821        catch (Exception ex)
 822        {
 0823            _logger.LogError(ex, "Failed to get match prediction costs by reprediction index for model {Model} and commu
 0824                model, communityContext);
 0825            throw;
 826        }
 1827    }
 828
 829    /// <summary>
 830    /// Get bonus prediction costs and counts grouped by reprediction index for cost analysis.
 831    /// Used specifically by the cost command to include all repredictions.
 832    /// </summary>
 833    public async Task<Dictionary<int, (double cost, int count)>> GetBonusPredictionCostsByRepredictionIndexAsync(
 834        string model,
 835        string communityContext,
 836        CancellationToken cancellationToken = default)
 837    {
 838        try
 839        {
 1840            var costsByIndex = new Dictionary<int, (double cost, int count)>();
 841
 842            // Query for bonus predictions with cost data
 1843            var query = _firestoreDb.Collection(_bonusPredictionsCollection)
 1844                .WhereEqualTo("competition", _competition)
 1845                .WhereEqualTo("model", model)
 1846                .WhereEqualTo("communityContext", communityContext);
 847
 1848            var snapshot = await query.GetSnapshotAsync(cancellationToken);
 849
 1850            foreach (var doc in snapshot.Documents)
 851            {
 1852                if (doc.Exists)
 853                {
 1854                    var prediction = doc.ConvertTo<FirestoreBonusPrediction>();
 1855                    var repredictionIndex = prediction.RepredictionIndex;
 856
 1857                    if (!costsByIndex.ContainsKey(repredictionIndex))
 858                    {
 1859                        costsByIndex[repredictionIndex] = (0.0, 0);
 860                    }
 861
 1862                    var (currentCost, currentCount) = costsByIndex[repredictionIndex];
 1863                    costsByIndex[repredictionIndex] = (currentCost + prediction.Cost, currentCount + 1);
 864                }
 865            }
 866
 1867            return costsByIndex;
 868        }
 0869        catch (Exception ex)
 870        {
 0871            _logger.LogError(ex, "Failed to get bonus prediction costs by reprediction index for model {Model} and commu
 0872                model, communityContext);
 0873            throw;
 874        }
 1875    }
 876
 877    private string? SerializeJustification(PredictionJustification? justification)
 878    {
 1879        if (justification == null)
 880        {
 1881            return null;
 882        }
 883
 1884        if (!HasJustificationContent(justification))
 885        {
 1886            return null;
 887        }
 888
 1889        var stored = new StoredJustification
 1890        {
 1891            KeyReasoning = justification.KeyReasoning?.Trim() ?? string.Empty,
 1892            ContextSources = new StoredContextSources
 1893            {
 1894                MostValuable = justification.ContextSources?.MostValuable?
 1895                    .Where(entry => entry != null)
 1896                    .Select(ToStoredContextSource)
 1897                    .ToList() ?? new List<StoredContextSource>(),
 1898                LeastValuable = justification.ContextSources?.LeastValuable?
 1899                    .Where(entry => entry != null)
 1900                    .Select(ToStoredContextSource)
 1901                    .ToList() ?? new List<StoredContextSource>()
 1902            },
 1903            Uncertainties = justification.Uncertainties?
 1904                .Where(item => !string.IsNullOrWhiteSpace(item))
 1905                .Select(item => item.Trim())
 1906                .ToList() ?? new List<string>()
 1907        };
 908
 1909        return JsonSerializer.Serialize(stored, JustificationSerializerOptions);
 910    }
 911
 912    private static bool HasJustificationContent(PredictionJustification justification)
 913    {
 1914        if (!string.IsNullOrWhiteSpace(justification.KeyReasoning))
 915        {
 1916            return true;
 917        }
 918
 1919        if (justification.ContextSources?.MostValuable != null &&
 1920            justification.ContextSources.MostValuable.Any(HasSourceContent))
 921        {
 1922            return true;
 923        }
 924
 1925        if (justification.ContextSources?.LeastValuable != null &&
 1926            justification.ContextSources.LeastValuable.Any(HasSourceContent))
 927        {
 1928            return true;
 929        }
 930
 1931        return justification.Uncertainties != null &&
 1932               justification.Uncertainties.Any(item => !string.IsNullOrWhiteSpace(item));
 933    }
 934
 935    private static bool HasSourceContent(PredictionJustificationContextSource source)
 936    {
 1937        return !string.IsNullOrWhiteSpace(source?.DocumentName) ||
 1938               !string.IsNullOrWhiteSpace(source?.Details);
 939    }
 940
 941    private PredictionJustification? DeserializeJustification(string? serialized)
 942    {
 1943        if (string.IsNullOrWhiteSpace(serialized))
 944        {
 1945            return null;
 946        }
 947
 1948        var trimmed = serialized.Trim();
 949
 1950        if (!trimmed.StartsWith("{"))
 951        {
 0952            return new PredictionJustification(
 0953                trimmed,
 0954                new PredictionJustificationContextSources(
 0955                    Array.Empty<PredictionJustificationContextSource>(),
 0956                    Array.Empty<PredictionJustificationContextSource>()),
 0957                Array.Empty<string>());
 958        }
 959
 960        try
 961        {
 1962            var stored = JsonSerializer.Deserialize<StoredJustification>(trimmed, JustificationSerializerOptions);
 963
 1964            if (stored == null)
 965            {
 0966                return null;
 967            }
 968
 1969            var contextSources = stored.ContextSources ?? new StoredContextSources();
 970
 1971            var mostValuable = contextSources.MostValuable?
 1972                .Where(entry => entry != null)
 1973                .Select(ToDomainContextSource)
 1974                .ToList() ?? new List<PredictionJustificationContextSource>();
 975
 1976            var leastValuable = contextSources.LeastValuable?
 1977                .Where(entry => entry != null)
 1978                .Select(ToDomainContextSource)
 1979                .ToList() ?? new List<PredictionJustificationContextSource>();
 980
 1981            var uncertainties = stored.Uncertainties?
 1982                .Where(item => !string.IsNullOrWhiteSpace(item))
 1983                .Select(item => item.Trim())
 1984                .ToList() ?? new List<string>();
 985
 1986            var justification = new PredictionJustification(
 1987                stored.KeyReasoning?.Trim() ?? string.Empty,
 1988                new PredictionJustificationContextSources(mostValuable, leastValuable),
 1989                uncertainties);
 990
 1991            return HasJustificationContent(justification) ? justification : null;
 992        }
 0993        catch (JsonException ex)
 994        {
 0995            _logger.LogWarning(ex, "Failed to parse structured justification JSON; falling back to legacy text format");
 996
 0997            var fallbackJustification = new PredictionJustification(
 0998                trimmed,
 0999                new PredictionJustificationContextSources(
 01000                    Array.Empty<PredictionJustificationContextSource>(),
 01001                    Array.Empty<PredictionJustificationContextSource>()),
 01002                Array.Empty<string>());
 1003
 01004            return HasJustificationContent(fallbackJustification) ? fallbackJustification : null;
 1005        }
 11006    }
 1007
 1008    private static StoredContextSource ToStoredContextSource(PredictionJustificationContextSource source)
 1009    {
 11010        return new StoredContextSource
 11011        {
 11012            DocumentName = source.DocumentName?.Trim() ?? string.Empty,
 11013            Details = source.Details?.Trim() ?? string.Empty
 11014        };
 1015    }
 1016
 1017    private static PredictionJustificationContextSource ToDomainContextSource(StoredContextSource source)
 1018    {
 11019        var documentName = source.DocumentName?.Trim() ?? string.Empty;
 11020        var details = source.Details?.Trim() ?? string.Empty;
 11021        return new PredictionJustificationContextSource(documentName, details);
 1022    }
 1023
 1024    private sealed class StoredJustification
 1025    {
 11026        public string? KeyReasoning { get; set; }
 11027        public StoredContextSources? ContextSources { get; set; }
 11028        public List<string>? Uncertainties { get; set; }
 1029    }
 1030
 1031    private sealed class StoredContextSources
 1032    {
 11033        public List<StoredContextSource>? MostValuable { get; set; }
 11034        public List<StoredContextSource>? LeastValuable { get; set; }
 1035    }
 1036
 1037    private sealed class StoredContextSource
 1038    {
 11039        public string? DocumentName { get; set; }
 11040        public string? Details { get; set; }
 1041    }
 1042}

Methods/Properties

.cctor()
.ctor(Google.Cloud.Firestore.FirestoreDb, Microsoft.Extensions.Logging.ILogger<FirebaseAdapter.FirebasePredictionRepository>)
SavePredictionAsync()
GetPredictionAsync()
GetPredictionAsync()
GetPredictionMetadataAsync()
GetMatchDayAsync()
GetMatchDayWithPredictionsAsync()
GetAllPredictionsAsync()
HasPredictionAsync()
SaveBonusPredictionAsync()
GetBonusPredictionAsync()
GetBonusPredictionByTextAsync()
GetBonusPredictionMetadataByTextAsync()
GetAllBonusPredictionsAsync()
HasBonusPredictionAsync()
StoreMatchAsync()
ConvertToTimestamp(NodaTime.ZonedDateTime)
ConvertFromTimestamp(Google.Cloud.Firestore.Timestamp)
GetMatchRepredictionIndexAsync()
GetBonusRepredictionIndexAsync()
SaveRepredictionAsync()
SaveBonusRepredictionAsync()
GetMatchPredictionCostsByRepredictionIndexAsync()
GetBonusPredictionCostsByRepredictionIndexAsync()
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)
get_KeyReasoning()
set_KeyReasoning(string)
get_ContextSources()
set_ContextSources(FirebaseAdapter.FirebasePredictionRepository.StoredContextSources)
get_Uncertainties()
set_Uncertainties(System.Collections.Generic.List<string>)
get_MostValuable()
set_MostValuable(System.Collections.Generic.List<FirebaseAdapter.FirebasePredictionRepository.StoredContextSource>)
get_LeastValuable()
set_LeastValuable(System.Collections.Generic.List<FirebaseAdapter.FirebasePredictionRepository.StoredContextSource>)
get_DocumentName()
set_DocumentName(string)
get_Details()
set_Details(string)