110 lines
6.1 KiB
C#
110 lines
6.1 KiB
C#
using QuestionsApi.Models;
|
|
|
|
namespace QuestionsApi.Services;
|
|
|
|
public static class MasteryCalculator
|
|
{
|
|
private static readonly IReadOnlyDictionary<int, double> DifficultyWeights = new Dictionary<int, double>
|
|
{
|
|
[1] = 0.6, [2] = 0.8, [3] = 1.0, [4] = 1.2, [5] = 1.4
|
|
};
|
|
|
|
public static List<DashboardSystemPerformance> Calculate(
|
|
IEnumerable<QuestionAttempt> attempts,
|
|
IEnumerable<QuestionReviewAttempt> reviews,
|
|
IEnumerable<ReviewQueueItem> queueItems,
|
|
IReadOnlyDictionary<string, Question> questions,
|
|
DateTime nowUtc)
|
|
{
|
|
var mappedAttempts = attempts.Where(attempt => questions.ContainsKey(attempt.QuestionExternalId)).ToList();
|
|
var answered = mappedAttempts.Where(attempt => !attempt.IsSkip).ToList();
|
|
var maxCycle = mappedAttempts.Count == 0 ? 0 : mappedAttempts.Max(attempt => attempt.BankCycle);
|
|
var reviewList = reviews.OrderBy(review => review.CreatedUtc).ToList();
|
|
var queueList = queueItems.ToList();
|
|
var results = new List<DashboardSystemPerformance>();
|
|
foreach (var system in Step3SystemCatalog.All)
|
|
{
|
|
var systemAttempts = answered.Where(attempt =>
|
|
Step3SystemCatalog.Normalize(questions[attempt.QuestionExternalId]) == system).ToList();
|
|
var ids = systemAttempts.Select(attempt => attempt.QuestionExternalId).Distinct(StringComparer.Ordinal).ToHashSet(StringComparer.Ordinal);
|
|
var systemReviews = reviewList.Where(review => ids.Contains(review.QuestionExternalId)).ToList();
|
|
var pending = queueList.Count(item => item.State == ReviewQueueStates.Pending
|
|
&& questions.TryGetValue(item.QuestionExternalId, out var question)
|
|
&& Step3SystemCatalog.Normalize(question) == system);
|
|
double earned = 0;
|
|
double possible = 0;
|
|
var advancedSuccesses = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (var attempt in systemAttempts)
|
|
{
|
|
var question = questions[attempt.QuestionExternalId];
|
|
var weight = DifficultyWeights.GetValueOrDefault(Math.Clamp(question.Difficulty, 1, 5), 1.0)
|
|
* Math.Pow(0.5, Math.Max(0, maxCycle - attempt.BankCycle));
|
|
var outcome = attempt.IsCorrect ? 1.0 : RecoveryOutcome(attempt, systemAttempts, systemReviews);
|
|
earned += weight * outcome;
|
|
possible += weight;
|
|
if (question.Difficulty >= 4 && outcome >= 0.75) advancedSuccesses.Add(question.ExternalId);
|
|
}
|
|
var score = possible == 0 ? 0 : (int)Math.Round(earned / possible * 100, MidpointRounding.AwayFromZero);
|
|
var state = State(ids.Count, score, advancedSuccesses.Count, pending);
|
|
var lastEvidence = systemAttempts.Select(value => (DateTime?)value.CreatedUtc)
|
|
.Concat(systemReviews.Select(value => (DateTime?)value.CreatedUtc)).Max();
|
|
var refreshDue = state is MasteryStates.Strong or MasteryStates.Mastered
|
|
&& lastEvidence.HasValue && lastEvidence.Value < nowUtc.AddDays(-30);
|
|
results.Add(new DashboardSystemPerformance
|
|
{
|
|
System = system,
|
|
Correct = systemAttempts.Count(value => value.IsCorrect),
|
|
Total = systemAttempts.Count,
|
|
Accuracy = DashboardStore.Percentage(systemAttempts.Count(value => value.IsCorrect), systemAttempts.Count),
|
|
MasteryScore = score,
|
|
MasteryState = state,
|
|
DistinctQuestions = ids.Count,
|
|
Difficulty4PlusSuccessful = advancedSuccesses.Count,
|
|
PendingReviews = pending,
|
|
LastEvidenceUtc = lastEvidence,
|
|
RefreshDue = refreshDue,
|
|
RecommendedAction = Action(state, pending, refreshDue)
|
|
});
|
|
}
|
|
return results;
|
|
}
|
|
|
|
private static double RecoveryOutcome(
|
|
QuestionAttempt attempt,
|
|
IReadOnlyCollection<QuestionAttempt> attempts,
|
|
IReadOnlyCollection<QuestionReviewAttempt> reviews)
|
|
{
|
|
var nextAttempt = attempts.Where(value => value.QuestionExternalId == attempt.QuestionExternalId
|
|
&& value.CreatedUtc > attempt.CreatedUtc)
|
|
.OrderBy(value => value.CreatedUtc).FirstOrDefault();
|
|
var recovery = reviews.Where(value => value.QuestionExternalId == attempt.QuestionExternalId
|
|
&& value.IsCorrect && value.CreatedUtc > attempt.CreatedUtc
|
|
&& (nextAttempt is null || value.CreatedUtc < nextAttempt.CreatedUtc))
|
|
.OrderBy(value => value.CreatedUtc).FirstOrDefault();
|
|
if (recovery is null) return 0;
|
|
return recovery.CreatedUtc - attempt.CreatedUtc >= TimeSpan.FromHours(24) ? 0.75 : 0.5;
|
|
}
|
|
|
|
private static string State(int distinct, int score, int advanced, int pending)
|
|
{
|
|
if (distinct == 0) return MasteryStates.Unseen;
|
|
if (distinct < 5) return MasteryStates.Learning;
|
|
if (distinct >= 25 && score >= 85 && advanced >= 5 && pending == 0) return MasteryStates.Mastered;
|
|
if (distinct >= 15 && score >= 75) return MasteryStates.Strong;
|
|
return MasteryStates.Practicing;
|
|
}
|
|
|
|
private static DashboardMasteryAction Action(string state, int pending, bool refreshDue)
|
|
{
|
|
if (pending > 0) return new() { Type = "review", Label = "Review missed concepts", QuestionCount = pending };
|
|
return state switch
|
|
{
|
|
MasteryStates.Unseen or MasteryStates.Learning => new() { Type = "practice", Label = "Start learning", QuestionCount = 10 },
|
|
MasteryStates.Practicing => new() { Type = "practice", Label = "Build mastery", QuestionCount = 10, DifficultyMin = 3, DifficultyMax = 4 },
|
|
MasteryStates.Strong => new() { Type = "practice", Label = "Challenge this system", QuestionCount = 10, DifficultyMin = 4, DifficultyMax = 5 },
|
|
MasteryStates.Mastered when refreshDue => new() { Type = "practice", Label = "Refresh mastery", QuestionCount = 5, DifficultyMin = 4, DifficultyMax = 5 },
|
|
_ => new() { Type = "practice", Label = "Maintain mastery", QuestionCount = 5, DifficultyMin = 4, DifficultyMax = 5 }
|
|
};
|
|
}
|
|
}
|