276 lines
15 KiB
C#
276 lines
15 KiB
C#
using MongoDB.Driver;
|
|
using QuestionsApi.Models;
|
|
|
|
namespace QuestionsApi.Services;
|
|
|
|
public sealed class WeeklyGoalStore
|
|
{
|
|
private readonly IMongoCollection<WeeklyGoal> _goals;
|
|
private readonly IMongoCollection<QuestionAttempt> _attempts;
|
|
private readonly IMongoCollection<QuestionReviewAttempt> _reviews;
|
|
private readonly IMongoCollection<ReviewQueueItem> _queue;
|
|
private readonly IMongoCollection<FlashcardReviewLog> _flashReviews;
|
|
private readonly IMongoCollection<FlashcardSchedule> _flashSchedules;
|
|
private readonly IMongoCollection<FlashcardNote> _flashNotes;
|
|
private readonly IMongoCollection<CcsAttempt> _ccsAttempts;
|
|
private readonly IMongoCollection<StudySession> _studySessions;
|
|
private readonly IMongoCollection<ExamSimulation> _simulations;
|
|
private readonly QuestionStore _questions;
|
|
private readonly CreditStore _credits;
|
|
private readonly TimeProvider _clock;
|
|
private readonly ServiceMetrics _metrics;
|
|
|
|
public WeeklyGoalStore(MongoDatabaseProvider mongo, QuestionStore questions, CreditStore credits,
|
|
TimeProvider clock, ServiceMetrics metrics)
|
|
{
|
|
var db = mongo.Database;
|
|
_goals = db.GetCollection<WeeklyGoal>("WeeklyGoals");
|
|
_attempts = db.GetCollection<QuestionAttempt>("QuestionAttempts");
|
|
_reviews = db.GetCollection<QuestionReviewAttempt>("QuestionReviewAttempts");
|
|
_queue = db.GetCollection<ReviewQueueItem>("ReviewQueueItems");
|
|
_flashReviews = db.GetCollection<FlashcardReviewLog>("FlashcardReviewLogs");
|
|
_flashSchedules = db.GetCollection<FlashcardSchedule>("FlashcardSchedules");
|
|
_flashNotes = db.GetCollection<FlashcardNote>("FlashcardNotes");
|
|
_ccsAttempts = db.GetCollection<CcsAttempt>("CcsAttempts");
|
|
_studySessions = db.GetCollection<StudySession>("StudySessions");
|
|
_simulations = db.GetCollection<ExamSimulation>("ExamSimulations");
|
|
_questions = questions;
|
|
_credits = credits;
|
|
_clock = clock;
|
|
_metrics = metrics;
|
|
_goals.Indexes.CreateOne(new CreateIndexModel<WeeklyGoal>(
|
|
Builders<WeeklyGoal>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.WeekStartUtc),
|
|
new CreateIndexOptions { Unique = true, Name = "uq_weekly_goal_user_start" }));
|
|
}
|
|
|
|
public async Task<WeeklyGoalEnvelope> GetAsync(
|
|
string userId, DashboardResponse dashboard, IReadOnlyCollection<Question> published)
|
|
{
|
|
var now = _clock.GetUtcNow().UtcDateTime;
|
|
var awards = await FinalizeExpiredAsync(userId, now);
|
|
var start = WeekStart(now);
|
|
var goal = await _goals.Find(value => value.UserId == userId && value.WeekStartUtc == start)
|
|
.FirstOrDefaultAsync();
|
|
if (goal is null)
|
|
{
|
|
var reviewed = await _reviews.CountDocumentsAsync(value => value.UserId == userId && value.IsCorrect
|
|
&& value.CreatedUtc >= start && value.CreatedUtc < start.AddDays(7));
|
|
var pending = await _queue.CountDocumentsAsync(value => value.UserId == userId
|
|
&& value.State == ReviewQueueStates.Pending);
|
|
var availableReviews = checked((int)Math.Min(int.MaxValue, reviewed + pending));
|
|
var remediation = ChooseRemediation(availableReviews);
|
|
var eligible = SelectHardSystems(dashboard, published);
|
|
goal = new WeeklyGoal
|
|
{
|
|
Id = $"{userId}|{start:yyyy-MM-dd}", UserId = userId, WeekStartUtc = start,
|
|
ExpiresUtc = start.AddDays(7), CreatedUtc = now,
|
|
RemediationType = remediation.Type, RemediationTarget = remediation.Target,
|
|
EligibleSystems = eligible
|
|
};
|
|
try
|
|
{
|
|
await _goals.InsertOneAsync(goal);
|
|
_metrics.WeeklyGoalAssigned(goal.RemediationType);
|
|
}
|
|
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
|
{
|
|
goal = await _goals.Find(value => value.UserId == userId && value.WeekStartUtc == start).FirstAsync();
|
|
}
|
|
}
|
|
var evaluated = await EvaluateAsync(goal);
|
|
awards.AddRange(evaluated.Awards);
|
|
return new WeeklyGoalEnvelope
|
|
{
|
|
State = evaluated.Response, CreditAwards = awards,
|
|
Credits = await _credits.GetStatusAsync(userId)
|
|
};
|
|
}
|
|
|
|
public static DateTime WeekStart(DateTime value)
|
|
{
|
|
var date = value.ToUniversalTime().Date;
|
|
var offset = ((int)date.DayOfWeek + 6) % 7;
|
|
return date.AddDays(-offset);
|
|
}
|
|
|
|
public static List<string> SelectHardSystems(
|
|
DashboardResponse dashboard, IReadOnlyCollection<Question> published)
|
|
{
|
|
var counts = published.Where(question => question.Difficulty >= 4)
|
|
.GroupBy(Step3SystemCatalog.Normalize).Where(group => group.Key is not null)
|
|
.ToDictionary(group => group.Key!, group => group.Count());
|
|
var ordered = dashboard.Systems.Where(system => counts.GetValueOrDefault(system.System) > 0)
|
|
.OrderBy(system => MasteryRank(system.MasteryState)).ThenBy(system => system.MasteryScore)
|
|
.ThenBy(system => system.Difficulty4PlusSuccessful).Select(system => system.System).ToList();
|
|
var result = new List<string>();
|
|
var available = 0;
|
|
foreach (var system in ordered)
|
|
{
|
|
result.Add(system);
|
|
available += counts[system];
|
|
if (available >= 5 || result.Count == 3) break;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
public static (string Type, int Target) ChooseRemediation(int availableReviews) =>
|
|
availableReviews >= 5
|
|
? (WeeklyRemediationTypes.Reviews, Math.Min(15, availableReviews))
|
|
: (WeeklyRemediationTypes.HardCorrect, 5);
|
|
|
|
public static int CountActiveDays(IEnumerable<DateTime> timestamps) =>
|
|
timestamps.Select(value => value.ToUniversalTime().Date).Distinct().Count();
|
|
|
|
public static bool QualifiesHardAttempt(WeeklyGoal goal, QuestionAttempt attempt, Question question) =>
|
|
!attempt.IsSkip && attempt.IsCorrect && attempt.CreatedUtc >= goal.WeekStartUtc
|
|
&& attempt.CreatedUtc < goal.ExpiresUtc && question.Difficulty >= 4
|
|
&& goal.EligibleSystems.Contains(Step3SystemCatalog.Normalize(question) ?? "");
|
|
|
|
private async Task<List<CreditAward>> FinalizeExpiredAsync(string userId, DateTime now)
|
|
{
|
|
var awards = new List<CreditAward>();
|
|
var expired = await _goals.Find(value => value.UserId == userId && value.ExpiresUtc <= now
|
|
&& !value.ExpirationRecorded).ToListAsync();
|
|
foreach (var goal in expired)
|
|
{
|
|
var wasCompleted = goal.CompletedUtc.HasValue;
|
|
var result = await EvaluateAsync(goal);
|
|
awards.AddRange(result.Awards);
|
|
var latest = await _goals.Find(value => value.Id == goal.Id).FirstAsync();
|
|
var recorded = await _goals.UpdateOneAsync(value => value.Id == goal.Id && !value.ExpirationRecorded,
|
|
Builders<WeeklyGoal>.Update.Set(value => value.ExpirationRecorded, true));
|
|
if (recorded.ModifiedCount == 1 && !latest.CompletedUtc.HasValue) _metrics.WeeklyGoalExpiredIncomplete();
|
|
if (recorded.ModifiedCount == 1 && !wasCompleted && latest.CompletedUtc.HasValue)
|
|
_metrics.WeeklyGoalLateFinalized();
|
|
}
|
|
return awards;
|
|
}
|
|
|
|
private async Task<(WeeklyGoalResponse Response, List<CreditAward> Awards)> EvaluateAsync(WeeklyGoal goal)
|
|
{
|
|
var evidence = await EvidenceAsync(goal);
|
|
var complete = evidence.ActiveDays >= 4 && evidence.Systems.Count >= 3
|
|
&& evidence.Remediation >= goal.RemediationTarget;
|
|
if (complete && !goal.CompletedUtc.HasValue)
|
|
{
|
|
var completedUtc = _clock.GetUtcNow().UtcDateTime;
|
|
var changed = await _goals.UpdateOneAsync(value => value.Id == goal.Id && value.CompletedUtc == null,
|
|
Builders<WeeklyGoal>.Update.Set(value => value.CompletedUtc, completedUtc));
|
|
goal.CompletedUtc = completedUtc;
|
|
if (changed.ModifiedCount == 1) _metrics.WeeklyGoalCompleted(goal.RemediationType);
|
|
}
|
|
var awards = new List<CreditAward>();
|
|
if (complete && !goal.RewardIssued)
|
|
{
|
|
var operationId = $"weekly-goal:{goal.Id}:complete";
|
|
var result = await _credits.AwardAsync(goal.UserId, new CreditAwardRequest(
|
|
operationId, 10, CreditReasonCodes.WeeklyGoalComplete,
|
|
"Completed the balanced weekly study goal", "weekly_goal", goal.Id, true));
|
|
if (result.Award is not null) awards.Add(result.Award);
|
|
var applied = result.Award ?? await _credits.GetAppliedAwardAsync(goal.UserId, operationId);
|
|
var amount = applied?.Amount ?? 0;
|
|
await _goals.UpdateOneAsync(value => value.Id == goal.Id && !value.RewardIssued,
|
|
Builders<WeeklyGoal>.Update.Set(value => value.RewardIssued, true)
|
|
.Set(value => value.RewardAmount, amount));
|
|
goal.RewardIssued = true;
|
|
goal.RewardAmount = amount;
|
|
}
|
|
return (Response(goal, evidence), awards);
|
|
}
|
|
|
|
private async Task<WeeklyEvidence> EvidenceAsync(WeeklyGoal goal)
|
|
{
|
|
var start = goal.WeekStartUtc;
|
|
var end = goal.ExpiresUtc;
|
|
var attemptsTask = _attempts.Find(value => value.UserId == goal.UserId && !value.IsSkip
|
|
&& value.CreatedUtc >= start && value.CreatedUtc < end).ToListAsync();
|
|
var reviewsTask = _reviews.Find(value => value.UserId == goal.UserId && value.IsCorrect
|
|
&& value.CreatedUtc >= start && value.CreatedUtc < end).ToListAsync();
|
|
var flashTask = _flashReviews.Find(value => value.UserId == goal.UserId && value.UndoneUtc == null
|
|
&& value.ReviewedUtc >= start && value.ReviewedUtc < end).ToListAsync();
|
|
var ccsTask = _ccsAttempts.Find(value => value.UserId == goal.UserId
|
|
&& value.State == CcsAttemptStates.Completed && value.CompletedUtc >= start && value.CompletedUtc < end)
|
|
.ToListAsync();
|
|
var blocksTask = _studySessions.Find(value => value.UserId == goal.UserId
|
|
&& value.GoalType == StudyGoalTypes.SimulationBlock && value.State == StudySessionStates.Completed
|
|
&& value.CompletedUtc >= start && value.CompletedUtc < end).ToListAsync();
|
|
var simulationsTask = _simulations.Find(value => value.UserId == goal.UserId
|
|
&& value.Responses.Any(response => response.UpdatedUtc >= start && response.UpdatedUtc < end)).ToListAsync();
|
|
await Task.WhenAll(attemptsTask, reviewsTask, flashTask, ccsTask, blocksTask, simulationsTask);
|
|
|
|
var attempts = attemptsTask.Result;
|
|
var reviews = reviewsTask.Result;
|
|
var flash = flashTask.Result;
|
|
var dates = attempts.Select(value => value.CreatedUtc)
|
|
.Concat(reviews.Select(value => value.CreatedUtc.Date))
|
|
.Concat(flash.Select(value => value.ReviewedUtc))
|
|
.Concat(ccsTask.Result.Select(value => value.CompletedUtc!.Value))
|
|
.Concat(blocksTask.Result.Select(value => value.CompletedUtc!.Value)).ToList();
|
|
|
|
var questionIds = attempts.Select(value => value.QuestionExternalId)
|
|
.Concat(reviews.Select(value => value.QuestionExternalId))
|
|
.Concat(simulationsTask.Result.SelectMany(value => value.Responses)
|
|
.Where(value => value.UpdatedUtc >= start && value.UpdatedUtc < end && !string.IsNullOrWhiteSpace(value.ChoiceId))
|
|
.Select(value => value.QuestionExternalId)).ToHashSet(StringComparer.Ordinal);
|
|
if (flash.Count > 0)
|
|
{
|
|
var cardIds = flash.Select(value => value.CardId).Distinct().ToList();
|
|
var schedules = await _flashSchedules.Find(value => cardIds.Contains(value.Id!)).ToListAsync();
|
|
var noteIds = schedules.Select(value => value.NoteId).Distinct().ToList();
|
|
var notes = await _flashNotes.Find(value => noteIds.Contains(value.Id!)
|
|
&& value.SourceQuestionExternalId != "").ToListAsync();
|
|
foreach (var note in notes) questionIds.Add(note.SourceQuestionExternalId);
|
|
}
|
|
var questionMap = await _questions.GetByExternalIdsAsync(questionIds);
|
|
var systems = questionMap.Values.Select(Step3SystemCatalog.Normalize)
|
|
.Where(value => !string.IsNullOrWhiteSpace(value)).Select(value => value!).Distinct().ToHashSet();
|
|
var hardCorrect = attempts.Count(attempt => questionMap.TryGetValue(attempt.QuestionExternalId, out var question)
|
|
&& QualifiesHardAttempt(goal, attempt, question));
|
|
return new WeeklyEvidence
|
|
{
|
|
ActiveDays = CountActiveDays(dates), Systems = systems,
|
|
Remediation = goal.RemediationType == WeeklyRemediationTypes.Reviews ? reviews.Count : hardCorrect
|
|
};
|
|
}
|
|
|
|
private static WeeklyGoalResponse Response(WeeklyGoal goal, WeeklyEvidence evidence)
|
|
{
|
|
var components = new List<WeeklyGoalComponentResponse>
|
|
{
|
|
Component("active_days", "Study on 4 different days", evidence.ActiveDays, 4),
|
|
Component("system_breadth", "Study 3 Step 3 systems", evidence.Systems.Count, 3),
|
|
Component(goal.RemediationType,
|
|
goal.RemediationType == WeeklyRemediationTypes.Reviews
|
|
? $"Complete {goal.RemediationTarget} scheduled reviews"
|
|
: "Get 5 difficulty 4+ answers correct in coverage gaps",
|
|
evidence.Remediation, goal.RemediationTarget)
|
|
};
|
|
return new WeeklyGoalResponse
|
|
{
|
|
WeekStartUtc = goal.WeekStartUtc, ExpiresUtc = goal.ExpiresUtc,
|
|
Completed = components.All(value => value.Completed), RemediationType = goal.RemediationType,
|
|
EligibleSystems = goal.EligibleSystems,
|
|
SuggestedSystem = goal.EligibleSystems.FirstOrDefault(value => !evidence.Systems.Contains(value))
|
|
?? goal.EligibleSystems.FirstOrDefault() ?? "",
|
|
RewardAmount = goal.RewardAmount, Components = components
|
|
};
|
|
}
|
|
|
|
private static WeeklyGoalComponentResponse Component(string type, string label, int progress, int target) =>
|
|
new() { Type = type, Label = label, Progress = Math.Min(progress, target), Target = target,
|
|
Completed = progress >= target };
|
|
|
|
private static int MasteryRank(string state) => state switch
|
|
{
|
|
MasteryStates.Unseen => 0, MasteryStates.Learning => 1, MasteryStates.Practicing => 2,
|
|
MasteryStates.Strong => 3, _ => 4
|
|
};
|
|
|
|
private sealed class WeeklyEvidence
|
|
{
|
|
public int ActiveDays { get; set; }
|
|
public HashSet<string> Systems { get; set; } = [];
|
|
public int Remediation { get; set; }
|
|
}
|
|
}
|