feat(learning): make practice scoring authoritative

This commit is contained in:
2026-08-03 09:44:57 +08:00
parent a9a8f0ab9f
commit 6bdfee6807
19 changed files with 22684 additions and 168 deletions

View File

@@ -1,5 +1,9 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System.Diagnostics.Metrics;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Tiku.Application.Learning;
using Tiku.Application.QuestionBanks;
@@ -16,17 +20,27 @@ public sealed class LearningActivityService(
TikuDbContext dbContext,
IQuestionReferenceService questionReferenceService,
IPublicQuestionAccessPolicy publicQuestionAccessPolicy,
ITenantExecutionScope tenantExecutionScope) : ILearningActivityService
ITenantExecutionScope tenantExecutionScope,
ILogger<LearningActivityService> logger) : ILearningActivityService
{
private const int DefaultLimit = 100;
private const int MaxLimit = 500;
private static readonly Meter LearningMeter = new("Tiku.Learning");
private static readonly Counter<long> IdempotencyReplays = LearningMeter.CreateCounter<long>("tiku.learning.idempotency.replays");
private static readonly Counter<long> AnswerConflicts = LearningMeter.CreateCounter<long>("tiku.learning.answer.conflicts");
private static readonly Counter<long> SubmissionConflicts = LearningMeter.CreateCounter<long>("tiku.learning.submission.conflicts");
private static readonly Counter<long> ScoringFailures = LearningMeter.CreateCounter<long>("tiku.learning.scoring.failures");
public async Task<LearningStatsItem> GetStatsAsync(
LearningActor actor,
CancellationToken cancellationToken = default)
{
var answers = dbContext.AnswerRecords.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
.Where(item => item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.IsCurrent &&
item.GradingStatus != AnswerGradingStatus.PendingReview &&
item.GradingStatus != AnswerGradingStatus.LegacyUnverified);
return new LearningStatsItem(
await answers.CountAsync(cancellationToken),
await answers.CountAsync(item => item.IsCorrect == true, cancellationToken),
@@ -46,7 +60,9 @@ public sealed class LearningActivityService(
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
cancellationToken),
await dbContext.PracticeSessionReports.AsNoTracking().CountAsync(
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
item => item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.Status == PracticeReportStatus.Final,
cancellationToken));
}
@@ -60,6 +76,9 @@ public sealed class LearningActivityService(
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.IsCurrent &&
item.GradingStatus != AnswerGradingStatus.PendingReview &&
item.GradingStatus != AnswerGradingStatus.LegacyUnverified &&
item.AnsweredAt >= since)
.Select(item => new { item.AnsweredAt, item.IsCorrect })
.ToArrayAsync(cancellationToken);
@@ -81,7 +100,10 @@ public sealed class LearningActivityService(
CancellationToken cancellationToken = default)
{
var rows = await dbContext.AnswerRecords.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId)
.Where(item => item.TenantId == actor.TenantId &&
item.IsCurrent &&
item.GradingStatus != AnswerGradingStatus.PendingReview &&
item.GradingStatus != AnswerGradingStatus.LegacyUnverified)
.GroupBy(item => item.UserId)
.Select(group => new
{
@@ -120,22 +142,18 @@ public sealed class LearningActivityService(
SubmitAnswerCommand command,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(command.IdempotencyKey))
{
throw new LearningValidationException("idempotency_key_required", "An idempotency key is required.");
}
if (command.SelectedOptionIndices?.Any(index => index < 0) == true)
{
throw new LearningValidationException("selected_option_index_invalid", "Selected option indices must be zero-based non-negative values.");
}
var now = DateTimeOffset.UtcNow;
var sessionQuestion = await dbContext.PracticeSessionQuestions
.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.Id == command.SessionQuestionId)
.Join(
dbContext.PracticeSessions.AsNoTracking().Where(session =>
session.TenantId == actor.TenantId &&
session.UserId == actor.UserId &&
session.FinishedAt == null &&
(!session.ExpiresAt.HasValue || session.ExpiresAt > now)),
item => new { item.TenantId, Id = item.PracticeSessionId },
session => new { session.TenantId, session.Id },
(item, session) => item)
.SingleOrDefaultAsync(cancellationToken);
var sessionQuestion = await dbContext.PracticeSessionQuestions.SingleOrDefaultAsync(item =>
item.TenantId == actor.TenantId && item.Id == command.SessionQuestionId, cancellationToken);
if (sessionQuestion is null)
{
@@ -144,49 +162,143 @@ public sealed class LearningActivityService(
"An active practice session question was not found.");
}
var session = await dbContext.PracticeSessions.SingleOrDefaultAsync(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.Id == sessionQuestion.PracticeSessionId, cancellationToken);
if (session is null)
{
throw new LearningResourceNotFoundException("practice_session_not_found", "Practice session was not found.");
}
var requestHash = HashAnswer(command);
var existingOperation = await dbContext.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.PracticeSessionId == session.Id &&
item.OperationType == "answer" &&
item.IdempotencyKey == command.IdempotencyKey, cancellationToken);
if (existingOperation is not null)
{
if (!string.Equals(existingOperation.RequestHash, requestHash, StringComparison.Ordinal))
{
AnswerConflicts.Add(1);
throw new LearningValidationException("idempotency_conflict", "The idempotency key was used with a different request.");
}
IdempotencyReplays.Add(1);
return existingOperation.ResponseSnapshot.Deserialize<AnswerRecordItem>()
?? throw new InvalidOperationException("The stored answer response is invalid.");
}
if (session.ExpiresAt.HasValue && session.ExpiresAt <= now)
{
session.Status = PracticeSessionStatus.Expired;
session.Version++;
await dbContext.SaveChangesAsync(cancellationToken);
throw new LearningValidationException("practice_session_expired", "The practice session has expired.");
}
EnsureAnswerSessionState(session, command);
var current = await dbContext.AnswerRecords.SingleOrDefaultAsync(answer =>
answer.TenantId == actor.TenantId &&
answer.UserId == actor.UserId &&
answer.PracticeSessionId == session.Id &&
answer.SessionQuestionId == sessionQuestion.Id &&
answer.IsCurrent, cancellationToken);
if (current is not null)
{
current.IsCurrent = false;
}
var selectedIndices = command.SelectedOptionIndices?.Distinct().Order().ToArray() ?? [];
var score = sessionQuestion.Score ?? 1;
QuestionGradingResult grading;
try
{
grading = QuestionGrader.Grade(new QuestionGradingInput(
sessionQuestion.QuestionType,
sessionQuestion.CorrectOptionIndexSnapshot,
sessionQuestion.CorrectOptionIndicesSnapshot,
sessionQuestion.AnswerTextSnapshot,
sessionQuestion.GradingRulesSnapshot,
selectedIndices,
command.AnswerText,
score));
}
catch (InvalidOperationException exception)
{
ScoringFailures.Add(1);
logger.LogWarning(exception,
"Question scoring failed for tenant {TenantId}, session {PracticeSessionId}, question {SessionQuestionId}",
actor.TenantId, session.Id, sessionQuestion.Id);
throw new LearningValidationException("question_grading_rule_invalid", exception.Message);
}
var record = new AnswerRecord
{
TenantId = actor.TenantId,
UserId = actor.UserId,
PracticeSessionId = sessionQuestion.PracticeSessionId,
SessionQuestionId = sessionQuestion.Id,
SelectedOptions = JsonSerializer.SerializeToElement(command.SelectedOptions ?? []),
SelectedOptions = JsonSerializer.SerializeToElement(selectedIndices),
AnswerText = command.AnswerText,
IsCorrect = command.SelfJudgedCorrect,
IsCorrect = grading.IsCorrect,
GradingStatus = grading.Status,
AwardedScore = grading.AwardedScore,
Revision = (current?.Revision ?? 0) + 1,
ClientSequence = command.ClientSequence,
IdempotencyKey = command.IdempotencyKey.Trim(),
RequestHash = requestHash,
IsCurrent = true,
AnsweredAt = now,
CreatedAt = now
};
dbContext.AnswerRecords.Add(record);
if (command.SelfJudgedCorrect == false)
session.Version++;
session.LastClientSequence = command.ClientSequence;
var response = ToItem(record, session.Version);
dbContext.LearningOperationIdempotencies.Add(new LearningOperationIdempotency
{
var wrongQuestion = await dbContext.WrongQuestions.FindAsync(
[actor.TenantId, actor.UserId, sessionQuestion.QuestionReferenceId],
cancellationToken);
TenantId = actor.TenantId,
UserId = actor.UserId,
PracticeSessionId = session.Id,
OperationType = "answer",
IdempotencyKey = command.IdempotencyKey.Trim(),
RequestHash = requestHash,
ResponseSnapshot = JsonSerializer.SerializeToElement(response),
CompletedAt = now
});
if (wrongQuestion is null)
try
{
await dbContext.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateConcurrencyException)
{
throw new LearningValidationException("practice_session_version_conflict", "The practice session changed. Reload it before answering.");
}
catch (DbUpdateException exception) when (
exception.InnerException is Npgsql.PostgresException postgresException &&
postgresException.SqlState == Npgsql.PostgresErrorCodes.UniqueViolation)
{
dbContext.ChangeTracker.Clear();
var replay = await dbContext.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.PracticeSessionId == session.Id &&
item.OperationType == "answer" &&
item.IdempotencyKey == command.IdempotencyKey, cancellationToken);
if (replay is not null && string.Equals(replay.RequestHash, requestHash, StringComparison.Ordinal))
{
dbContext.WrongQuestions.Add(new WrongQuestion
{
TenantId = actor.TenantId,
UserId = actor.UserId,
QuestionReferenceId = sessionQuestion.QuestionReferenceId,
QuestionOwnerTenantId = sessionQuestion.QuestionOwnerTenantId,
QuestionId = sessionQuestion.QuestionId,
WrongCount = 1,
LastWrongAt = now
});
}
else
{
wrongQuestion.WrongCount++;
wrongQuestion.LastWrongAt = now;
wrongQuestion.ResolvedAt = null;
IdempotencyReplays.Add(1);
return replay.ResponseSnapshot.Deserialize<AnswerRecordItem>()
?? throw new InvalidOperationException("The stored answer response is invalid.");
}
AnswerConflicts.Add(1);
throw new LearningValidationException("practice_answer_conflict", "The answer conflicted with another client operation.");
}
await dbContext.SaveChangesAsync(cancellationToken);
return ToItem(record);
return response;
}
public async Task<LearningList<FavoriteQuestionItem>> GetFavoriteQuestionsAsync(
@@ -664,11 +776,23 @@ public sealed class LearningActivityService(
};
dbContext.PracticeSessions.Add(session);
await dbContext.SaveChangesAsync(cancellationToken);
var selections = await LoadQuestionSelectionsAsync(
actor.TenantId,
questionReferenceIds,
cancellationToken);
foreach (var selection in selections)
{
if (!QuestionGrader.HasValidAuthoritativeAnswer(
selection.QuestionType,
selection.CorrectOptionIndex,
selection.CorrectOptionIndices,
selection.AnswerText))
{
throw new LearningValidationException(
"practice_question_grading_rule_invalid",
$"Question '{selection.QuestionId}' has no valid authoritative grading rule.");
}
}
var scorePerQuestion = session.TotalScore.HasValue && selections.Count > 0
? session.TotalScore.Value / selections.Count
: (decimal?)null;
@@ -682,7 +806,19 @@ public sealed class LearningActivityService(
QuestionId = selection.QuestionId,
QuestionVersionId = selection.QuestionVersionId,
Position = index,
Score = scorePerQuestion
Score = scorePerQuestion,
QuestionType = selection.QuestionType,
TypeLabelSnapshot = selection.TypeLabel,
DifficultySnapshot = selection.Difficulty,
TagsSnapshot = selection.Tags,
ContentSnapshot = selection.Content,
OptionsSnapshot = selection.Options,
CorrectOptionIndexSnapshot = selection.CorrectOptionIndex,
CorrectOptionIndicesSnapshot = selection.CorrectOptionIndices,
AnswerTextSnapshot = selection.AnswerText,
ExplanationSnapshot = selection.Explanation,
GradingRulesSnapshot = BuildGradingRules(selection),
SnapshotVersion = 1
}));
dbContext.PracticeAccessEvents.Add(new PracticeAccessEvent
{
@@ -717,39 +853,104 @@ public sealed class LearningActivityService(
.Where(answer =>
answer.TenantId == actor.TenantId &&
answer.UserId == actor.UserId &&
answer.PracticeSessionId == session.Id)
answer.PracticeSessionId == session.Id &&
answer.IsCurrent)
.ToArrayAsync(cancellationToken);
var answersByQuestion = answers
.GroupBy(answer => answer.SessionQuestionId)
.ToDictionary(
group => group.Key,
group => ToItem(group.OrderByDescending(answer => answer.AnsweredAt).First()));
group => ToItem(
group.OrderByDescending(answer => answer.Revision).First(),
session.Version));
return new PracticeSessionDetailItem(ToItem(session), orderedQuestions, answersByQuestion);
}
public async Task<PracticeSessionReportItem> SubmitPracticeSessionAsync(
LearningActor actor,
PracticeSessionFilter filter,
SubmitPracticeSessionCommand command,
CancellationToken cancellationToken = default)
{
var session = await GetPracticeSessionAsync(actor, filter.PracticeSessionId, cancellationToken);
var existing = await dbContext.PracticeSessionReports
.AsNoTracking()
.SingleOrDefaultAsync(
report =>
report.TenantId == actor.TenantId &&
report.PracticeSessionId == session.Id,
cancellationToken);
if (existing is not null)
if (string.IsNullOrWhiteSpace(command.IdempotencyKey))
{
return ToItem(existing);
throw new LearningValidationException("idempotency_key_required", "An idempotency key is required.");
}
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
var session = await GetPracticeSessionAsync(actor, command.PracticeSessionId, cancellationToken);
var requestHash = HashSubmission(command);
var existingOperation = await dbContext.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.PracticeSessionId == session.Id &&
item.OperationType == "submit" &&
item.IdempotencyKey == command.IdempotencyKey, cancellationToken);
if (existingOperation is not null)
{
if (!string.Equals(existingOperation.RequestHash, requestHash, StringComparison.Ordinal))
{
SubmissionConflicts.Add(1);
throw new LearningValidationException("idempotency_conflict", "The idempotency key was used with a different request.");
}
IdempotencyReplays.Add(1);
return existingOperation.ResponseSnapshot.Deserialize<PracticeSessionReportItem>()
?? throw new InvalidOperationException("The stored report response is invalid.");
}
if (session.Status != PracticeSessionStatus.Active)
{
throw new LearningValidationException("practice_session_not_active", "Only an active practice session can be submitted.");
}
if (session.ExpiresAt.HasValue && session.ExpiresAt <= DateTimeOffset.UtcNow)
{
session.Status = PracticeSessionStatus.Expired;
session.Version++;
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
throw new LearningValidationException("practice_session_expired", "The practice session has expired.");
}
if (session.Version != command.ExpectedSessionVersion)
{
throw new LearningValidationException("practice_session_version_conflict", "The practice session changed. Reload it before submitting.");
}
session.Status = PracticeSessionStatus.Scoring;
session.Version++;
var report = await BuildPracticeSessionReportAsync(actor, session, cancellationToken);
session.FinishedAt ??= report.SubmittedAt;
await dbContext.SaveChangesAsync(cancellationToken);
return ToItem(report);
session.Status = report.IsFinal ? PracticeSessionStatus.Submitted : PracticeSessionStatus.PendingReview;
session.FinishedAt = report.SubmittedAt;
session.Version++;
var response = ToItem(report);
dbContext.LearningOperationIdempotencies.Add(new LearningOperationIdempotency
{
TenantId = actor.TenantId,
UserId = actor.UserId,
PracticeSessionId = session.Id,
OperationType = "submit",
IdempotencyKey = command.IdempotencyKey.Trim(),
RequestHash = requestHash,
ResponseSnapshot = JsonSerializer.SerializeToElement(response),
CompletedAt = report.SubmittedAt
});
try
{
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch (DbUpdateConcurrencyException)
{
SubmissionConflicts.Add(1);
throw new LearningValidationException("practice_session_version_conflict", "The practice session changed during submission.");
}
catch (DbUpdateException exception) when (exception.InnerException is Npgsql.NpgsqlException)
{
SubmissionConflicts.Add(1);
throw new LearningValidationException("practice_submission_conflict", "The practice session was already submitted by another request.");
}
return response;
}
public async Task<PracticeSessionReportItem> GetPracticeSessionReportAsync(
@@ -851,7 +1052,7 @@ public sealed class LearningActivityService(
row.session.StartedAt,
row.session.FinishedAt,
row.session.ExpiresAt,
PracticeSessionStatus(row.session, now),
ResolvePracticeSessionHistoryStatus(row.session, now),
row.report?.Id,
row.report?.Score,
row.report?.TotalScore,
@@ -1055,7 +1256,17 @@ public sealed class LearningActivityService(
reference.Id,
reference.QuestionOwnerTenantId,
reference.QuestionId,
version.Id))
version.Id,
question.Type,
question.TypeLabel,
question.Difficulty,
question.Tags,
version.Content,
version.Options,
version.CorrectOptionIndex,
version.CorrectOptionIndices,
version.AnswerText,
version.Explanation))
.ToArrayAsync(token);
},
cancellationToken);
@@ -1085,21 +1296,6 @@ public sealed class LearningActivityService(
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
return await (
from sessionQuestion in systemDbContext.PracticeSessionQuestions.AsNoTracking()
join question in systemDbContext.Questions.AsNoTracking()
on new
{
TenantId = sessionQuestion.QuestionOwnerTenantId,
Id = sessionQuestion.QuestionId
}
equals new { question.TenantId, question.Id }
join version in systemDbContext.QuestionVersions.AsNoTracking()
on new
{
TenantId = sessionQuestion.QuestionOwnerTenantId,
sessionQuestion.QuestionId,
Id = sessionQuestion.QuestionVersionId
}
equals new { version.TenantId, version.QuestionId, version.Id }
where sessionQuestion.TenantId == tenantId &&
sessionQuestion.PracticeSessionId == practiceSessionId
orderby sessionQuestion.Position
@@ -1111,15 +1307,14 @@ public sealed class LearningActivityService(
? QuestionSource.Tenant
: QuestionSource.Platform,
sessionQuestion.QuestionId),
question.Id,
question.Type,
question.TypeLabel,
question.Difficulty,
question.Tags,
version.Id,
version.Content,
version.Options,
version.Explanation))
sessionQuestion.QuestionId,
sessionQuestion.QuestionType,
sessionQuestion.TypeLabelSnapshot,
sessionQuestion.DifficultySnapshot,
sessionQuestion.TagsSnapshot,
sessionQuestion.QuestionVersionId,
sessionQuestion.ContentSnapshot,
sessionQuestion.OptionsSnapshot))
.ToArrayAsync(token);
},
cancellationToken);
@@ -1172,32 +1367,35 @@ public sealed class LearningActivityService(
.Where(answer =>
answer.TenantId == actor.TenantId &&
answer.UserId == actor.UserId &&
answer.PracticeSessionId == session.Id)
answer.PracticeSessionId == session.Id &&
answer.IsCurrent)
.ToArrayAsync(cancellationToken);
var latestAnswers = answers
.GroupBy(answer => answer.SessionQuestionId)
.ToDictionary(
group => group.Key,
group => group.OrderByDescending(answer => answer.AnsweredAt).First());
var latestAnswers = answers.ToDictionary(answer => answer.SessionQuestionId);
var totalQuestions = sessionQuestions.Length;
var answeredCount = sessionQuestions.Count(question => latestAnswers.ContainsKey(question.Id));
var correctCount = sessionQuestions.Count(question =>
latestAnswers.TryGetValue(question.Id, out var answer) &&
answer.IsCorrect == true);
answer.GradingStatus == AnswerGradingStatus.Correct);
var wrongCount = sessionQuestions.Count(question =>
latestAnswers.TryGetValue(question.Id, out var answer) &&
answer.IsCorrect != true);
answer.GradingStatus == AnswerGradingStatus.Incorrect);
var pendingReviewCount = sessionQuestions.Count(question =>
latestAnswers.TryGetValue(question.Id, out var answer) &&
answer.GradingStatus == AnswerGradingStatus.PendingReview);
var unansweredCount = Math.Max(0, totalQuestions - answeredCount);
var totalScore = session.TotalScore ?? totalQuestions;
var scorePerQuestion = totalQuestions == 0 ? 0 : totalScore / totalQuestions;
var score = Math.Round(correctCount * scorePerQuestion, 2);
var accuracy = totalQuestions == 0 ? 0 : Math.Round((decimal)correctCount / totalQuestions, 4);
var totalScore = sessionQuestions.Sum(question => question.Score ?? 1);
var score = Math.Round(answers.Sum(answer => answer.AwardedScore ?? 0), 2);
var objectivelyGradedCount = correctCount + wrongCount;
var accuracy = objectivelyGradedCount == 0
? 0
: Math.Round((decimal)correctCount / objectivelyGradedCount, 4);
var isFinal = pendingReviewCount == 0;
var submittedAt = DateTimeOffset.UtcNow;
var durationSeconds = Math.Max(0, (int)(submittedAt - session.StartedAt).TotalSeconds);
var wrongQuestionIds = sessionQuestions
.Where(question =>
latestAnswers.TryGetValue(question.Id, out var answer) &&
answer.IsCorrect != true)
answer.GradingStatus == AnswerGradingStatus.Incorrect)
.Select(question => question.QuestionReferenceId)
.ToArray();
var questionResults = sessionQuestions
@@ -1211,10 +1409,15 @@ public sealed class LearningActivityService(
questionId = question.QuestionId,
source = question.QuestionOwnerTenantId == actor.TenantId ? "tenant" : "platform",
answered = answer is not null,
isCorrect = answer?.IsCorrect,
score = answer?.IsCorrect == true ? scorePerQuestion : 0,
totalScore = scorePerQuestion,
answeredAt = answer?.AnsweredAt
gradingStatus = answer?.GradingStatus.ToString(),
isCorrect = isFinal ? answer?.IsCorrect : null,
score = answer?.AwardedScore,
totalScore = question.Score ?? 1,
answeredAt = answer?.AnsweredAt,
correctOptionIndex = isFinal ? question.CorrectOptionIndexSnapshot : null,
correctOptionIndices = isFinal ? question.CorrectOptionIndicesSnapshot : JsonDefaults.Array(),
answerText = isFinal ? question.AnswerTextSnapshot : null,
explanation = isFinal ? question.ExplanationSnapshot : null
};
})
.ToArray();
@@ -1232,6 +1435,7 @@ public sealed class LearningActivityService(
score,
totalScore,
accuracy,
pendingReviewCount,
sortOrder = 0
}
};
@@ -1255,13 +1459,17 @@ public sealed class LearningActivityService(
DurationSeconds = durationSeconds,
StartedAt = session.StartedAt,
SubmittedAt = submittedAt,
Status = isFinal ? PracticeReportStatus.Final : PracticeReportStatus.PendingReview,
Version = 1,
IsFinal = isFinal,
PendingReviewCount = pendingReviewCount,
ScoringVersion = 1,
SectionStats = JsonSerializer.SerializeToElement(sectionStats),
QuestionResults = JsonSerializer.SerializeToElement(questionResults),
WrongQuestionIds = JsonSerializer.SerializeToElement(wrongQuestionIds),
Metadata = JsonSerializer.SerializeToElement(new
{
scoringVersion = 1,
scorePerQuestion
scoringVersion = 1
})
};
dbContext.PracticeSessionReports.Add(report);
@@ -1283,6 +1491,33 @@ public sealed class LearningActivityService(
SortOrder = 0
});
foreach (var question in sessionQuestions.Where(question =>
latestAnswers.TryGetValue(question.Id, out var answer) &&
answer.GradingStatus == AnswerGradingStatus.Incorrect))
{
var wrongQuestion = await dbContext.WrongQuestions.FindAsync(
[actor.TenantId, actor.UserId, question.QuestionReferenceId], cancellationToken);
if (wrongQuestion is null)
{
dbContext.WrongQuestions.Add(new WrongQuestion
{
TenantId = actor.TenantId,
UserId = actor.UserId,
QuestionReferenceId = question.QuestionReferenceId,
QuestionOwnerTenantId = question.QuestionOwnerTenantId,
QuestionId = question.QuestionId,
WrongCount = 1,
LastWrongAt = submittedAt
});
}
else
{
wrongQuestion.WrongCount++;
wrongQuestion.LastWrongAt = submittedAt;
wrongQuestion.ResolvedAt = null;
}
}
return report;
}
@@ -1322,7 +1557,7 @@ public sealed class LearningActivityService(
}
}
private static AnswerRecordItem ToItem(AnswerRecord record)
private static AnswerRecordItem ToItem(AnswerRecord record, long sessionVersion)
{
return new AnswerRecordItem(
record.Id,
@@ -1330,7 +1565,10 @@ public sealed class LearningActivityService(
record.PracticeSessionId,
record.SelectedOptions,
record.AnswerText,
record.IsCorrect,
record.GradingStatus == AnswerGradingStatus.PendingReview ? "pending_review" : "accepted",
record.Revision,
record.ClientSequence,
sessionVersion,
record.AnsweredAt);
}
@@ -1372,7 +1610,9 @@ public sealed class LearningActivityService(
item.FinishedAt,
item.ExpiresAt,
item.Metadata,
PracticeSessionStatus(item, DateTimeOffset.UtcNow));
item.Status,
item.Version,
item.LastClientSequence);
}
private static PracticeSessionReportItem ToItem(PracticeSessionReport item)
@@ -1394,6 +1634,11 @@ public sealed class LearningActivityService(
item.DurationSeconds,
item.StartedAt,
item.SubmittedAt,
item.Status,
item.Version,
item.IsFinal,
item.PendingReviewCount,
item.ScoringVersion,
item.SectionStats,
item.QuestionResults,
item.WrongQuestionIds,
@@ -1416,14 +1661,60 @@ public sealed class LearningActivityService(
.ToList();
}
private static string PracticeSessionStatus(PracticeSession session, DateTimeOffset now)
private static void EnsureAnswerSessionState(
PracticeSession session,
SubmitAnswerCommand command)
{
if (session.FinishedAt.HasValue)
if (session.Status != PracticeSessionStatus.Active)
{
throw new LearningValidationException("practice_session_not_active", "Only an active practice session accepts answers.");
}
if (session.Version != command.ExpectedSessionVersion)
{
throw new LearningValidationException("practice_session_version_conflict", "The practice session changed. Reload it before answering.");
}
if (command.ClientSequence <= session.LastClientSequence)
{
throw new LearningValidationException("practice_client_sequence_conflict", "Client sequence must increase within a practice session.");
}
}
private static JsonElement BuildGradingRules(QuestionSelection selection) =>
JsonSerializer.SerializeToElement(new
{
version = 1,
normalization = selection.QuestionType.Equals("fill_blank", StringComparison.OrdinalIgnoreCase)
? "nfkc_trim_casefold_whitespace"
: "exact"
});
private static string HashAnswer(SubmitAnswerCommand command) => Hash(JsonSerializer.Serialize(new
{
command.SessionQuestionId,
command.ExpectedSessionVersion,
command.ClientSequence,
selectedOptionIndices = command.SelectedOptionIndices?.Distinct().Order().ToArray() ?? [],
answerText = command.AnswerText?.Trim()
}));
private static string HashSubmission(SubmitPracticeSessionCommand command) => Hash(JsonSerializer.Serialize(new
{
command.PracticeSessionId,
command.ExpectedSessionVersion
}));
private static string Hash(string value) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
private static string ResolvePracticeSessionHistoryStatus(PracticeSession session, DateTimeOffset now)
{
if (session.Status is PracticeSessionStatus.Submitted or PracticeSessionStatus.PendingReview)
{
return "finished";
}
if (session.ExpiresAt.HasValue && session.ExpiresAt.Value <= now)
if (session.Status == PracticeSessionStatus.Expired ||
session.ExpiresAt.HasValue && session.ExpiresAt.Value <= now)
{
return "expired";
}
@@ -1479,7 +1770,17 @@ public sealed class LearningActivityService(
Guid QuestionReferenceId,
Guid QuestionOwnerTenantId,
Guid QuestionId,
Guid QuestionVersionId);
Guid QuestionVersionId,
string QuestionType,
string? TypeLabel,
int? Difficulty,
JsonElement Tags,
string? Content,
JsonElement Options,
int? CorrectOptionIndex,
JsonElement CorrectOptionIndices,
string? AnswerText,
string? Explanation);
}
public class LearningException(string code, string message) : Exception(message)