Add clinical progression ranks and records
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class ProgressionStore
|
||||
{
|
||||
private readonly IMongoCollection<UserProgression> _progressions;
|
||||
private readonly IMongoCollection<ProgressionWeeklySnapshot> _snapshots;
|
||||
private readonly IMongoCollection<QuestionAttempt> _attempts;
|
||||
private readonly IMongoCollection<QuestionReviewAttempt> _reviews;
|
||||
private readonly IMongoCollection<ReviewQueueItem> _queue;
|
||||
private readonly IMongoCollection<StudySession> _sessions;
|
||||
private readonly QuestionStore _questions;
|
||||
private readonly CreditStore _credits;
|
||||
private readonly TimeProvider _clock;
|
||||
|
||||
public ProgressionStore(MongoDatabaseProvider mongo, QuestionStore questions, CreditStore credits, TimeProvider clock)
|
||||
{
|
||||
var db = mongo.Database;
|
||||
_progressions = db.GetCollection<UserProgression>("UserProgressions");
|
||||
_snapshots = db.GetCollection<ProgressionWeeklySnapshot>("ProgressionWeeklySnapshots");
|
||||
_attempts = db.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||
_reviews = db.GetCollection<QuestionReviewAttempt>("QuestionReviewAttempts");
|
||||
_queue = db.GetCollection<ReviewQueueItem>("ReviewQueueItems");
|
||||
_sessions = db.GetCollection<StudySession>("StudySessions");
|
||||
_questions = questions;
|
||||
_credits = credits;
|
||||
_clock = clock;
|
||||
_progressions.Indexes.CreateOne(new CreateIndexModel<UserProgression>(
|
||||
Builders<UserProgression>.IndexKeys.Ascending(x => x.UserId), new CreateIndexOptions { Unique = true }));
|
||||
_snapshots.Indexes.CreateOne(new CreateIndexModel<ProgressionWeeklySnapshot>(
|
||||
Builders<ProgressionWeeklySnapshot>.IndexKeys.Ascending(x => x.UserId).Descending(x => x.WeekStartUtc)));
|
||||
}
|
||||
|
||||
public async Task PrepareAsync(string userId)
|
||||
{
|
||||
if (await _progressions.Find(x => x.UserId == userId).AnyAsync())
|
||||
{
|
||||
await EnsureWeeklyOpeningAsync(userId);
|
||||
return;
|
||||
}
|
||||
var evidence = await EvidenceAsync(userId);
|
||||
var qualified = ProgressionPolicy.Qualified(evidence.Systems, evidence.Retention);
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
var progression = new UserProgression
|
||||
{
|
||||
UserId = userId, HighestRankOrdinal = qualified, BaselineApplied = true,
|
||||
TrackingStartedUtc = now, UpdatedUtc = now,
|
||||
EarnedRanksUtc = Enumerable.Range(0, qualified + 1).ToDictionary(x => x.ToString(), _ => now)
|
||||
};
|
||||
ApplyHistoricalRecords(progression, evidence);
|
||||
try { await _progressions.InsertOneAsync(progression); }
|
||||
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) { }
|
||||
await EnsureWeeklyOpeningAsync(userId, evidence);
|
||||
}
|
||||
|
||||
public async Task<(ProgressionResponse State, List<CreditAward> Awards)> RefreshAsync(string userId)
|
||||
{
|
||||
await PrepareAsync(userId);
|
||||
var evidence = await EvidenceAsync(userId);
|
||||
var progression = await _progressions.Find(x => x.UserId == userId).FirstAsync();
|
||||
var oldHighest = progression.HighestRankOrdinal;
|
||||
var qualified = ProgressionPolicy.Qualified(evidence.Systems, evidence.Retention);
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
ApplyHistoricalRecords(progression, evidence);
|
||||
if (qualified > progression.HighestRankOrdinal)
|
||||
{
|
||||
progression.HighestRankOrdinal = qualified;
|
||||
for (var i = oldHighest + 1; i <= qualified; i++) progression.EarnedRanksUtc.TryAdd(i.ToString(), now);
|
||||
}
|
||||
await UpdateWeeklyAsync(progression, evidence, now);
|
||||
progression.UpdatedUtc = now;
|
||||
var update = Builders<UserProgression>.Update.Max(x => x.HighestRankOrdinal, qualified)
|
||||
.Set(x => x.CurrentHardStreak, progression.CurrentHardStreak)
|
||||
.Set(x => x.BestHardStreak, progression.BestHardStreak)
|
||||
.Set(x => x.BestHardStreakUtc, progression.BestHardStreakUtc)
|
||||
.Set(x => x.BestTimedBlock, progression.BestTimedBlock)
|
||||
.Set(x => x.SystemsAdvancedWeek, progression.SystemsAdvancedWeek)
|
||||
.Set(x => x.ReviewQueueReduction, progression.ReviewQueueReduction)
|
||||
.Set(x => x.WeakSystemImprovement, progression.WeakSystemImprovement)
|
||||
.Set(x => x.UpdatedUtc, now);
|
||||
for (var i = oldHighest + 1; i <= qualified; i++) update = update.Set($"EarnedRanksUtc.{i}", now);
|
||||
await _progressions.UpdateOneAsync(x => x.UserId == userId, update);
|
||||
progression = await _progressions.Find(x => x.UserId == userId).FirstAsync();
|
||||
|
||||
var awards = new List<CreditAward>();
|
||||
foreach (var tier in new[] { 3, 6, 9, 12 }.Where(x => oldHighest < x && qualified >= x))
|
||||
{
|
||||
var amount = tier switch { 3 => 5, 6 => 10, 9 => 15, _ => 25 };
|
||||
var result = await _credits.AwardAsync(userId, new CreditAwardRequest(
|
||||
$"progression:rank:{tier}", amount, CreditReasonCodes.RankPromotion,
|
||||
$"Advanced to {ProgressionPolicy.Name(tier)}", "progression_rank", tier.ToString(), false));
|
||||
if (result.Award is not null) awards.Add(result.Award);
|
||||
}
|
||||
return (Response(progression, qualified, evidence), awards);
|
||||
}
|
||||
|
||||
public async Task<ProgressionResponse> GetAsync(string userId) => (await RefreshAsync(userId)).State;
|
||||
|
||||
private async Task EnsureWeeklyOpeningAsync(string userId, Evidence? supplied = null)
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
var week = WeekStart(now);
|
||||
var id = $"{userId}:{week:yyyyMMdd}";
|
||||
if (await _snapshots.Find(x => x.Id == id).AnyAsync()) return;
|
||||
var evidence = supplied ?? await EvidenceAsync(userId);
|
||||
var values = Snapshots(evidence.Systems);
|
||||
var snapshot = new ProgressionWeeklySnapshot
|
||||
{
|
||||
Id = id, UserId = userId, WeekStartUtc = week,
|
||||
OpeningSystems = values, LatestSystems = values,
|
||||
OpeningPendingReviews = evidence.Pending, LatestPendingReviews = evidence.Pending, UpdatedUtc = now
|
||||
};
|
||||
try { await _snapshots.InsertOneAsync(snapshot); }
|
||||
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) { }
|
||||
}
|
||||
|
||||
private async Task UpdateWeeklyAsync(UserProgression progression, Evidence evidence, DateTime now)
|
||||
{
|
||||
await EnsureWeeklyOpeningAsync(progression.UserId, evidence);
|
||||
var week = WeekStart(now);
|
||||
var id = $"{progression.UserId}:{week:yyyyMMdd}";
|
||||
await _snapshots.UpdateOneAsync(x => x.Id == id, Builders<ProgressionWeeklySnapshot>.Update
|
||||
.Set(x => x.LatestSystems, Snapshots(evidence.Systems)).Set(x => x.LatestPendingReviews, evidence.Pending)
|
||||
.Set(x => x.UpdatedUtc, now));
|
||||
var all = await _snapshots.Find(x => x.UserId == progression.UserId).ToListAsync();
|
||||
foreach (var snapshot in all)
|
||||
{
|
||||
var opening = snapshot.OpeningSystems.ToDictionary(x => x.System);
|
||||
var advanced = snapshot.LatestSystems.Count(x => opening.TryGetValue(x.System, out var before) && x.StateOrdinal > before.StateOrdinal);
|
||||
SetRecordIfBetter(progression.SystemsAdvancedWeek, advanced, 0, $"Week of {snapshot.WeekStartUtc:yyyy-MM-dd}", snapshot.UpdatedUtc);
|
||||
var reduced = Math.Max(0, snapshot.OpeningPendingReviews - snapshot.LatestPendingReviews);
|
||||
SetRecordIfBetter(progression.ReviewQueueReduction, reduced, snapshot.OpeningPendingReviews,
|
||||
$"Week of {snapshot.WeekStartUtc:yyyy-MM-dd}", snapshot.UpdatedUtc);
|
||||
foreach (var after in snapshot.LatestSystems)
|
||||
{
|
||||
if (!opening.TryGetValue(after.System, out var before) || before.MasteryScore >= 75 || before.DistinctQuestions < 5) continue;
|
||||
SetRecordIfBetter(progression.WeakSystemImprovement, Math.Max(0, after.MasteryScore - before.MasteryScore),
|
||||
after.DistinctQuestions, after.System, snapshot.UpdatedUtc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Evidence> EvidenceAsync(string userId)
|
||||
{
|
||||
var attempts = await _attempts.Find(x => x.UserId == userId).SortBy(x => x.CreatedUtc).ToListAsync();
|
||||
var reviews = await _reviews.Find(x => x.UserId == userId).SortBy(x => x.CreatedUtc).ToListAsync();
|
||||
var queue = await _queue.Find(x => x.UserId == userId).ToListAsync();
|
||||
var ids = attempts.Select(x => x.QuestionExternalId).Concat(reviews.Select(x => x.QuestionExternalId)).Concat(queue.Select(x => x.QuestionExternalId));
|
||||
var questions = await _questions.GetByExternalIdsAsync(ids);
|
||||
var systems = MasteryCalculator.Calculate(attempts, reviews, queue, questions, _clock.GetUtcNow().UtcDateTime);
|
||||
var delayed = reviews.Where(x => x.IsCorrect).Select(x => new
|
||||
{
|
||||
Review = x, Item = queue.FirstOrDefault(q => q.QuestionExternalId == x.QuestionExternalId)
|
||||
}).Where(x => x.Item is not null && x.Review.CreatedUtc - x.Item.CreatedUtc >= TimeSpan.FromHours(24))
|
||||
.Select(x => x.Review.QuestionExternalId).Distinct().Count();
|
||||
var directHard = attempts.Where(x => x.IsCorrect && !x.IsSkip && questions.TryGetValue(x.QuestionExternalId, out var q) && q.Difficulty >= 4)
|
||||
.Select(x => x.QuestionExternalId).Distinct().Count();
|
||||
var sessions = await _sessions.Find(x => x.UserId == userId && x.State == StudySessionStates.Completed
|
||||
&& x.Mode == "Timed" && x.GoalType != StudyGoalTypes.SimulationBlock).ToListAsync();
|
||||
return new Evidence(attempts, reviews, queue, sessions, questions, systems, delayed + directHard / 5,
|
||||
queue.Count(x => x.State == ReviewQueueStates.Pending));
|
||||
}
|
||||
|
||||
private void ApplyHistoricalRecords(UserProgression progression, Evidence evidence)
|
||||
{
|
||||
var streak = 0;
|
||||
foreach (var attempt in evidence.Attempts.Where(x => evidence.Questions.TryGetValue(x.QuestionExternalId, out var q) && q.Difficulty >= 4))
|
||||
{
|
||||
streak = attempt.IsCorrect && !attempt.IsSkip ? streak + 1 : 0;
|
||||
if (streak > progression.BestHardStreak)
|
||||
{
|
||||
progression.BestHardStreak = streak;
|
||||
progression.BestHardStreakUtc = attempt.CreatedUtc;
|
||||
}
|
||||
}
|
||||
progression.CurrentHardStreak = streak;
|
||||
|
||||
var attemptsById = evidence.Attempts.Where(x => x.Id is not null).ToDictionary(x => x.Id!);
|
||||
foreach (var session in evidence.Sessions)
|
||||
{
|
||||
var sessionAttempts = session.AppliedEventIds.Where(attemptsById.ContainsKey).Select(x => attemptsById[x]).ToList();
|
||||
if (sessionAttempts.Count < 20 || sessionAttempts.Count != session.AppliedEventIds.Count) continue;
|
||||
var hard = sessionAttempts.Count(x => evidence.Questions.TryGetValue(x.QuestionExternalId, out var q) && q.Difficulty >= 4);
|
||||
if (hard * 2 < sessionAttempts.Count) continue;
|
||||
var accuracy = (int)Math.Round(sessionAttempts.Count(x => x.IsCorrect && !x.IsSkip) * 100d / sessionAttempts.Count);
|
||||
if (accuracy > progression.BestTimedBlock.Value
|
||||
|| accuracy == progression.BestTimedBlock.Value && sessionAttempts.Count > progression.BestTimedBlock.SampleSize)
|
||||
progression.BestTimedBlock = new PersonalRecordValue { Value = accuracy, SampleSize = sessionAttempts.Count,
|
||||
Detail = $"{hard} difficulty 4–5 questions", AchievedUtc = session.CompletedUtc ?? session.UpdatedUtc };
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetRecordIfBetter(PersonalRecordValue record, int value, int sample, string detail, DateTime date)
|
||||
{
|
||||
if (value <= record.Value) return;
|
||||
record.Value = value; record.SampleSize = sample; record.Detail = detail; record.AchievedUtc = date;
|
||||
}
|
||||
|
||||
private static ProgressionResponse Response(UserProgression p, int qualified, Evidence evidence)
|
||||
{
|
||||
var earnedTierNames = Enumerable.Range(0, p.HighestRankOrdinal / 3 + 1).Select(x => ProgressionPolicy.Names[x]).ToList();
|
||||
var response = new ProgressionResponse
|
||||
{
|
||||
RankOrdinal = p.HighestRankOrdinal, QualifiedRankOrdinal = qualified,
|
||||
RankName = ProgressionPolicy.Name(p.HighestRankOrdinal), Division = ProgressionPolicy.Division(p.HighestRankOrdinal),
|
||||
TrackingStartedUtc = p.TrackingStartedUtc, EarnedBadges = earnedTierNames, EarnedRanksUtc = p.EarnedRanksUtc,
|
||||
Records = Records(p), Ladder = Enumerable.Range(0, ProgressionPolicy.Thresholds.Length).Select(i =>
|
||||
{
|
||||
var t = ProgressionPolicy.Thresholds[i];
|
||||
var parts = new List<string>();
|
||||
if (t.L > 0) parts.Add($"Learning+ {t.L}"); if (t.P > 0) parts.Add($"Practicing+ {t.P}");
|
||||
if (t.S > 0) parts.Add($"Strong+ {t.S}"); if (t.M > 0) parts.Add($"Mastered {t.M}");
|
||||
if (t.H > 0) parts.Add($"Hard successes {t.H}"); if (t.R > 0) parts.Add($"Retention {t.R}");
|
||||
return new ProgressionRankResponse { Ordinal=i, RankName=ProgressionPolicy.Name(i), Division=ProgressionPolicy.Division(i),
|
||||
Earned=i <= p.HighestRankOrdinal, EarnedUtc=p.EarnedRanksUtc.GetValueOrDefault(i.ToString()),
|
||||
RequirementSummary=parts.Count == 0 ? "Starting rank" : string.Join(" · ", parts) };
|
||||
}).ToList()
|
||||
};
|
||||
if (p.HighestRankOrdinal >= ProgressionPolicy.Thresholds.Length - 1) return response;
|
||||
var next = p.HighestRankOrdinal + 1;
|
||||
response.NextRankName = ProgressionPolicy.Name(next); response.NextDivision = ProgressionPolicy.Division(next);
|
||||
var threshold = ProgressionPolicy.Thresholds[next];
|
||||
var values = new Dictionary<string, (string Label, int Current, int Required)>
|
||||
{
|
||||
["learning"] = ("Systems at Learning+", evidence.Systems.Count(x => ProgressionPolicy.State(x.MasteryState) >= 1), threshold.L),
|
||||
["practicing"] = ("Systems at Practicing+", evidence.Systems.Count(x => ProgressionPolicy.State(x.MasteryState) >= 2), threshold.P),
|
||||
["strong"] = ("Systems at Strong+", evidence.Systems.Count(x => ProgressionPolicy.State(x.MasteryState) >= 3), threshold.S),
|
||||
["mastered"] = ("Systems Mastered", evidence.Systems.Count(x => ProgressionPolicy.State(x.MasteryState) >= 4), threshold.M),
|
||||
["hard"] = ("Difficulty 4+ successes", evidence.Systems.Sum(x => x.Difficulty4PlusSuccessful), threshold.H),
|
||||
["retention"] = ("Retention evidence", evidence.Retention, threshold.R)
|
||||
};
|
||||
response.NextRequirements = values.Where(x => x.Value.Required > 0).Select(x => new ProgressionRequirementResponse
|
||||
{ Key = x.Key, Label = x.Value.Label, Current = x.Value.Current, Required = x.Value.Required }).ToList();
|
||||
return response;
|
||||
}
|
||||
|
||||
private static List<ProgressionRecordResponse> Records(UserProgression p) =>
|
||||
[
|
||||
new() { Key="timed_block", Label="Advanced Timed Block", Definition="Best accuracy in a completed 20+ question Timed block with at least 50% difficulty 4–5.", Value=p.BestTimedBlock.Value, SampleSize=p.BestTimedBlock.SampleSize, Detail=p.BestTimedBlock.Detail, AchievedUtc=p.BestTimedBlock.AchievedUtc },
|
||||
new() { Key="hard_streak", Label="Hard-Question Streak", Definition="Consecutive correct first attempts at difficulty 4–5; easier questions are ignored.", Value=p.BestHardStreak, AchievedUtc=p.BestHardStreakUtc },
|
||||
new() { Key="systems_week", Label="Systems Advanced in a Week", Definition="Unique systems whose mastery state increased in one UTC Monday–Sunday week.", Value=p.SystemsAdvancedWeek.Value, Detail=p.SystemsAdvancedWeek.Detail, AchievedUtc=p.SystemsAdvancedWeek.AchievedUtc, TrackingFromLaunch=true },
|
||||
new() { Key="reviews_week", Label="Review Queue Reduction", Definition="Largest weekly decrease in pending reviews.", Value=p.ReviewQueueReduction.Value, SampleSize=p.ReviewQueueReduction.SampleSize, Detail=p.ReviewQueueReduction.Detail, AchievedUtc=p.ReviewQueueReduction.AchievedUtc, TrackingFromLaunch=true },
|
||||
new() { Key="weak_gain", Label="Weak-System Improvement", Definition="Largest weekly mastery-score gain in a system that began below 75 with at least five questions of evidence.", Value=p.WeakSystemImprovement.Value, SampleSize=p.WeakSystemImprovement.SampleSize, Detail=p.WeakSystemImprovement.Detail, AchievedUtc=p.WeakSystemImprovement.AchievedUtc, TrackingFromLaunch=true }
|
||||
];
|
||||
|
||||
private static List<ProgressionSystemSnapshot> Snapshots(IEnumerable<DashboardSystemPerformance> systems) => systems.Select(x => new ProgressionSystemSnapshot
|
||||
{ System=x.System, StateOrdinal=ProgressionPolicy.State(x.MasteryState), MasteryScore=x.MasteryScore, DistinctQuestions=x.DistinctQuestions }).ToList();
|
||||
private static DateTime WeekStart(DateTime value) => value.Date.AddDays(-((7 + (int)value.DayOfWeek - (int)DayOfWeek.Monday) % 7));
|
||||
private sealed record Evidence(List<QuestionAttempt> Attempts, List<QuestionReviewAttempt> Reviews, List<ReviewQueueItem> Queue, List<StudySession> Sessions,
|
||||
IReadOnlyDictionary<string, Question> Questions, List<DashboardSystemPerformance> Systems, int Retention, int Pending);
|
||||
}
|
||||
Reference in New Issue
Block a user