Add daily study missions
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class DailyMissionStore
|
||||
{
|
||||
private readonly IMongoCollection<DailyMissionSet> _sets;
|
||||
private readonly CreditStore _credits;
|
||||
private readonly TimeProvider _clock;
|
||||
private readonly ServiceMetrics _metrics;
|
||||
|
||||
public DailyMissionStore(MongoDatabaseProvider mongo, CreditStore credits, TimeProvider clock, ServiceMetrics metrics)
|
||||
{
|
||||
_sets = mongo.Database.GetCollection<DailyMissionSet>("DailyMissionSets");
|
||||
_credits = credits;
|
||||
_clock = clock;
|
||||
_metrics = metrics;
|
||||
_sets.Indexes.CreateOne(new CreateIndexModel<DailyMissionSet>(
|
||||
Builders<DailyMissionSet>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.DateUtc),
|
||||
new CreateIndexOptions { Unique = true, Name = "uq_daily_mission_user_date" }));
|
||||
}
|
||||
|
||||
public async Task<DailyMissionEnvelope> GetOrCreateAsync(
|
||||
string userId, DashboardResponse dashboard, IReadOnlyCollection<Question> published)
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
await RecordExpiredAsync(userId, now);
|
||||
var key = DateKey(now);
|
||||
var existing = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstOrDefaultAsync();
|
||||
if (existing is not null) return Envelope(existing);
|
||||
var candidates = BuildCandidates(dashboard, published, now);
|
||||
if (candidates.Count < 3) throw new InvalidOperationException("Not enough eligible question-bank coverage to create daily missions.");
|
||||
var set = new DailyMissionSet
|
||||
{
|
||||
Id = $"{userId}|{key}", UserId = userId, DateUtc = key,
|
||||
Missions = candidates.Take(3).ToList(), ReplacementCandidates = candidates.Skip(3).ToList(),
|
||||
CreatedUtc = now, ExpiresUtc = now.Date.AddDays(1)
|
||||
};
|
||||
try
|
||||
{
|
||||
await _sets.InsertOneAsync(set);
|
||||
foreach (var mission in set.Missions) _metrics.DailyMissionAssigned(mission.Type);
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
set = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstAsync();
|
||||
}
|
||||
return Envelope(set);
|
||||
}
|
||||
|
||||
public async Task<DailyMissionEnvelope?> ReplaceAsync(string userId, string missionId)
|
||||
{
|
||||
var key = DateKey(_clock.GetUtcNow().UtcDateTime);
|
||||
var set = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstOrDefaultAsync();
|
||||
if (set is null) return null;
|
||||
if (set.ReplacementUsed) throw new InvalidOperationException("Today's mission replacement has already been used.");
|
||||
var index = set.Missions.FindIndex(value => value.Id == missionId);
|
||||
if (index < 0) throw new ArgumentException("Mission not found.");
|
||||
if (set.Missions[index].CompletedUtc.HasValue) throw new InvalidOperationException("Completed missions cannot be replaced.");
|
||||
if (set.ReplacementCandidates.Count == 0) throw new InvalidOperationException("No replacement mission is available.");
|
||||
var replacement = set.ReplacementCandidates[0];
|
||||
replacement.StartsUtc = _clock.GetUtcNow().UtcDateTime;
|
||||
set.Missions[index] = replacement;
|
||||
set.ReplacementCandidates.RemoveAt(0);
|
||||
set.ReplacementUsed = true;
|
||||
var changed = await _sets.ReplaceOneAsync(value => value.Id == set.Id && !value.ReplacementUsed, set);
|
||||
if (changed.ModifiedCount != 1) throw new InvalidOperationException("Today's mission replacement has already been used.");
|
||||
_metrics.DailyMissionReplaced();
|
||||
return Envelope(set);
|
||||
}
|
||||
|
||||
public Task<DailyMissionEnvelope?> RecordQuestionAsync(string userId, QuestionAttempt attempt, Question question) =>
|
||||
!attempt.IsCorrect || attempt.IsSkip ? Task.FromResult<DailyMissionEnvelope?>(null) :
|
||||
RecordAsync(userId, $"question:{attempt.Id}", attempt.CreatedUtc,
|
||||
mission => QualifiesQuestion(mission, attempt, question));
|
||||
|
||||
public Task<DailyMissionEnvelope?> RecordReviewAsync(string userId, QuestionReviewAttempt attempt) =>
|
||||
!attempt.IsCorrect ? Task.FromResult<DailyMissionEnvelope?>(null) :
|
||||
RecordAsync(userId, $"review:{attempt.Id}", attempt.CreatedUtc,
|
||||
mission => QualifiesReview(mission, attempt));
|
||||
|
||||
public static bool QualifiesQuestion(DailyMission mission, QuestionAttempt attempt, Question question)
|
||||
{
|
||||
if (!attempt.IsCorrect || attempt.IsSkip || attempt.CreatedUtc < mission.StartsUtc) return false;
|
||||
var system = Step3SystemCatalog.Normalize(question);
|
||||
return mission.Type == DailyMissionTypes.WeakSystemCorrect && mission.Step3System == system
|
||||
|| mission.Type == DailyMissionTypes.DifficultyFourCorrect
|
||||
&& mission.Step3System == system && question.Difficulty >= 4;
|
||||
}
|
||||
|
||||
public static bool QualifiesReview(DailyMission mission, QuestionReviewAttempt attempt) =>
|
||||
attempt.IsCorrect && attempt.CreatedUtc >= mission.StartsUtc && mission.Type == DailyMissionTypes.DueReviews;
|
||||
|
||||
private async Task<DailyMissionEnvelope?> RecordAsync(
|
||||
string userId, string eventId, DateTime eventUtc, Func<DailyMission, bool> qualifies)
|
||||
{
|
||||
var key = DateKey(_clock.GetUtcNow().UtcDateTime);
|
||||
var set = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstOrDefaultAsync();
|
||||
if (set is null) return null;
|
||||
foreach (var mission in set.Missions.Where(value => value.CompletedUtc is null
|
||||
&& value.StartsUtc <= eventUtc && qualifies(value)))
|
||||
{
|
||||
var missionFilter = Builders<DailyMission>.Filter.Eq(value => value.Id, mission.Id)
|
||||
& Builders<DailyMission>.Filter.Eq(value => value.CompletedUtc, null)
|
||||
& Builders<DailyMission>.Filter.AnyNin(value => value.AppliedEventIds, [eventId]);
|
||||
var filter = Builders<DailyMissionSet>.Filter.Eq(value => value.Id, set.Id)
|
||||
& Builders<DailyMissionSet>.Filter.ElemMatch(value => value.Missions, missionFilter);
|
||||
await _sets.UpdateOneAsync(filter, Builders<DailyMissionSet>.Update
|
||||
.Inc("Missions.$.Progress", 1).AddToSet("Missions.$.AppliedEventIds", eventId));
|
||||
}
|
||||
set = await _sets.Find(value => value.Id == set.Id).FirstAsync();
|
||||
var awards = new List<CreditAward>();
|
||||
foreach (var mission in set.Missions.Where(value => value.CompletedUtc is null && value.Progress >= value.Target))
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
var filter = Builders<DailyMissionSet>.Filter.Eq(value => value.Id, set.Id)
|
||||
& Builders<DailyMissionSet>.Filter.ElemMatch(value => value.Missions,
|
||||
item => item.Id == mission.Id && item.CompletedUtc == null && item.Progress >= item.Target);
|
||||
var completed = await _sets.UpdateOneAsync(filter,
|
||||
Builders<DailyMissionSet>.Update.Set("Missions.$.CompletedUtc", now));
|
||||
if (completed.ModifiedCount != 1) continue;
|
||||
var award = await _credits.AwardAsync(userId, new CreditAwardRequest(
|
||||
$"mission:{mission.Id}:complete", 3, CreditReasonCodes.DailyMissionComplete,
|
||||
$"Completed daily mission: {mission.Title}", "daily_mission", mission.Id, true));
|
||||
if (award.Award is not null) awards.Add(award.Award);
|
||||
_metrics.DailyMissionCompleted(mission.Type);
|
||||
}
|
||||
set = await _sets.Find(value => value.Id == set.Id).FirstAsync();
|
||||
return new DailyMissionEnvelope { State = Response(set), CreditAwards = awards };
|
||||
}
|
||||
|
||||
public static List<DailyMission> BuildCandidates(
|
||||
DashboardResponse dashboard, IReadOnlyCollection<Question> published, DateTime now)
|
||||
{
|
||||
var availability = published.GroupBy(Step3SystemCatalog.Normalize)
|
||||
.Where(group => !string.IsNullOrWhiteSpace(group.Key))
|
||||
.ToDictionary(group => group.Key!, group => (All: group.Count(), Hard: group.Count(q => q.Difficulty >= 4)));
|
||||
var systems = dashboard.Systems.Where(value => availability.ContainsKey(value.System))
|
||||
.OrderBy(value => MasteryRank(value.MasteryState)).ThenBy(value => value.MasteryScore)
|
||||
.ThenBy(value => value.DistinctQuestions).ThenBy(value => value.System).ToList();
|
||||
var result = new List<DailyMission>();
|
||||
if (dashboard.MissedQuestionCount > 0)
|
||||
result.Add(New(DailyMissionTypes.DueReviews,
|
||||
$"Complete {Math.Min(10, dashboard.MissedQuestionCount)} scheduled reviews", "", 0,
|
||||
Math.Min(10, dashboard.MissedQuestionCount), now));
|
||||
var weak = systems.Where(value => availability[value.System].All >= 3).Select(system =>
|
||||
New(DailyMissionTypes.WeakSystemCorrect,
|
||||
$"Get 3 first-attempt answers correct in {system.System}", system.System, 0, 3, now));
|
||||
var hard = systems.Where(value => availability[value.System].Hard >= 3)
|
||||
.OrderBy(value => value.Difficulty4PlusSuccessful).ThenBy(value => value.MasteryScore)
|
||||
.Select(system => New(DailyMissionTypes.DifficultyFourCorrect,
|
||||
$"Get 3 difficulty 4+ answers correct in {system.System}", system.System, 4, 3, now));
|
||||
var weakList = weak.ToList();
|
||||
var hardList = hard.ToList();
|
||||
if (weakList.Count > 0) result.Add(weakList[0]);
|
||||
var firstHard = hardList.FirstOrDefault(value => value.Step3System != weakList.FirstOrDefault()?.Step3System)
|
||||
?? hardList.FirstOrDefault();
|
||||
if (firstHard is not null) result.Add(firstHard);
|
||||
result.AddRange(weakList.Skip(1));
|
||||
result.AddRange(hardList.Where(value => value.Id != firstHard?.Id));
|
||||
return result.GroupBy(value => $"{value.Type}|{value.Step3System}").Select(group => group.First()).ToList();
|
||||
}
|
||||
|
||||
private static DailyMission New(string type, string title, string system, int difficulty, int target, DateTime now) =>
|
||||
new() { Type = type, Title = title, Step3System = system, DifficultyMin = difficulty,
|
||||
Target = target, StartsUtc = now };
|
||||
|
||||
private static int MasteryRank(string state) => state switch
|
||||
{
|
||||
MasteryStates.Unseen => 0, MasteryStates.Learning => 1, MasteryStates.Practicing => 2,
|
||||
MasteryStates.Strong => 3, _ => 4
|
||||
};
|
||||
|
||||
private static string DateKey(DateTime now) => now.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
private async Task RecordExpiredAsync(string userId, DateTime now)
|
||||
{
|
||||
var expired = await _sets.Find(value => value.UserId == userId && value.ExpiresUtc <= now
|
||||
&& !value.ExpirationRecorded).ToListAsync();
|
||||
foreach (var set in expired)
|
||||
{
|
||||
var changed = await _sets.UpdateOneAsync(value => value.Id == set.Id && !value.ExpirationRecorded,
|
||||
Builders<DailyMissionSet>.Update.Set(value => value.ExpirationRecorded, true));
|
||||
if (changed.ModifiedCount != 1) continue;
|
||||
foreach (var mission in set.Missions.Where(value => value.CompletedUtc is null))
|
||||
_metrics.DailyMissionExpired(mission.Type);
|
||||
}
|
||||
}
|
||||
private static DailyMissionEnvelope Envelope(DailyMissionSet set) => new() { State = Response(set) };
|
||||
private static DailyMissionSetResponse Response(DailyMissionSet set) => new()
|
||||
{
|
||||
DateUtc = set.DateUtc, ExpiresUtc = set.ExpiresUtc,
|
||||
ReplacementAvailable = !set.ReplacementUsed && set.ReplacementCandidates.Count > 0
|
||||
&& set.Missions.Any(value => value.CompletedUtc is null),
|
||||
Missions = set.Missions.Select(value => new DailyMissionResponse
|
||||
{
|
||||
Id = value.Id, Type = value.Type, Title = value.Title, Step3System = value.Step3System,
|
||||
DifficultyMin = value.DifficultyMin, Target = value.Target,
|
||||
Progress = Math.Min(value.Progress, value.Target), Completed = value.CompletedUtc.HasValue
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,10 @@ public sealed class ServiceMetrics
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditCoins = new();
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditDuplicates = new();
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditCapped = new();
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dailyMissionAssigned = new();
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dailyMissionCompleted = new();
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dailyMissionExpired = new();
|
||||
private long _dailyMissionReplaced;
|
||||
|
||||
public void AttemptCreated() => Interlocked.Increment(ref _attemptsCreated);
|
||||
public void AttemptDuplicate() => Interlocked.Increment(ref _attemptDuplicates);
|
||||
@@ -54,6 +58,10 @@ public sealed class ServiceMetrics
|
||||
}
|
||||
public void CreditDuplicate(string reason) => _creditDuplicates.AddOrUpdate(reason, 1, (_, value) => value + 1);
|
||||
public void CreditCapped(string reason) => _creditCapped.AddOrUpdate(reason, 1, (_, value) => value + 1);
|
||||
public void DailyMissionAssigned(string type) => _dailyMissionAssigned.AddOrUpdate(type, 1, (_, value) => value + 1);
|
||||
public void DailyMissionCompleted(string type) => _dailyMissionCompleted.AddOrUpdate(type, 1, (_, value) => value + 1);
|
||||
public void DailyMissionExpired(string type) => _dailyMissionExpired.AddOrUpdate(type, 1, (_, value) => value + 1);
|
||||
public void DailyMissionReplaced() => Interlocked.Increment(ref _dailyMissionReplaced);
|
||||
|
||||
public string RenderPrometheus()
|
||||
{
|
||||
@@ -85,6 +93,16 @@ public sealed class ServiceMetrics
|
||||
AppendReasonSample(output, "step3_credit_award_duplicates_total", reason, _creditDuplicates.GetValueOrDefault(reason));
|
||||
AppendReasonSample(output, "step3_credit_award_capped_total", reason, _creditCapped.GetValueOrDefault(reason));
|
||||
}
|
||||
output.AppendLine("# TYPE step3_daily_missions_assigned_total counter");
|
||||
output.AppendLine("# TYPE step3_daily_missions_completed_total counter");
|
||||
output.AppendLine("# TYPE step3_daily_missions_expired_incomplete_total counter");
|
||||
foreach (var type in _dailyMissionAssigned.Keys.Concat(_dailyMissionCompleted.Keys).Concat(_dailyMissionExpired.Keys).Distinct().Order())
|
||||
{
|
||||
AppendMissionSample(output, "step3_daily_missions_assigned_total", type, _dailyMissionAssigned.GetValueOrDefault(type));
|
||||
AppendMissionSample(output, "step3_daily_missions_completed_total", type, _dailyMissionCompleted.GetValueOrDefault(type));
|
||||
AppendMissionSample(output, "step3_daily_missions_expired_incomplete_total", type, _dailyMissionExpired.GetValueOrDefault(type));
|
||||
}
|
||||
AppendCounter(output, "step3_daily_missions_replaced_total", Interlocked.Read(ref _dailyMissionReplaced));
|
||||
output.AppendLine("# TYPE step3_http_request_duration_seconds summary");
|
||||
output.Append("step3_http_request_duration_seconds_count ")
|
||||
.Append(Interlocked.Read(ref _httpRequests))
|
||||
@@ -116,4 +134,7 @@ public sealed class ServiceMetrics
|
||||
|
||||
private static void AppendReasonSample(StringBuilder output, string name, string reason, long value) =>
|
||||
output.Append(name).Append("{reason=\"").Append(reason.Replace("\"", "")).Append("\"} ").Append(value).AppendLine();
|
||||
|
||||
private static void AppendMissionSample(StringBuilder output, string name, string type, long value) =>
|
||||
output.Append(name).Append("{type=\"").Append(type.Replace("\"", "")).Append("\"} ").Append(value).AppendLine();
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ public sealed class UserProfileStore
|
||||
"ExternalIdentities", "ExternalAuthTransactions",
|
||||
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
|
||||
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews",
|
||||
"UserCreditAccounts", "CreditLedgerEntries", "StudySessions"
|
||||
"UserCreditAccounts", "CreditLedgerEntries", "StudySessions", "DailyMissionSets"
|
||||
];
|
||||
|
||||
public UserProfileStore(MongoDatabaseProvider mongo)
|
||||
|
||||
Reference in New Issue
Block a user