242 lines
11 KiB
C#
242 lines
11 KiB
C#
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Npgsql;
|
|
using Tiku.Application.Learning;
|
|
using Tiku.Domain.Common;
|
|
using Tiku.Domain.Content;
|
|
using Tiku.Domain.Learning;
|
|
|
|
namespace Tiku.Infrastructure.Learning;
|
|
|
|
internal sealed class PracticeSessionService(LearningServiceDependencies dependencies)
|
|
: LearningActivityServiceBase(dependencies), IPracticeSessionService
|
|
{
|
|
public async Task<PracticeSessionItem> CreatePracticeSessionAsync(
|
|
LearningActor actor,
|
|
PracticeSessionCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var assembly = await BuildPracticeAssemblyAsync(actor.TenantId, command, cancellationToken);
|
|
var questionReferenceIds = await CollectQuestionReferenceIdsAsync(actor, assembly, cancellationToken);
|
|
if (questionReferenceIds.Count == 0)
|
|
throw new LearningValidationException("no_practice_questions",
|
|
"No published questions are available for this practice target.");
|
|
|
|
|
|
var containsPlatformQuestion = await dbContext.TenantQuestionReferences.AsNoTracking().AnyAsync(
|
|
reference =>
|
|
reference.TenantId == actor.TenantId &&
|
|
questionReferenceIds.Contains(reference.Id) &&
|
|
reference.Source == QuestionSource.Platform,
|
|
cancellationToken);
|
|
if (containsPlatformQuestion)
|
|
await publicQuestionAccessPolicy.EnsureCanStartAsync(actor.TenantId, cancellationToken);
|
|
|
|
var now = DateTimeOffset.UtcNow;
|
|
var session = new PracticeSession
|
|
{
|
|
TenantId = actor.TenantId,
|
|
UserId = actor.UserId,
|
|
Mode = assembly.Mode,
|
|
TargetType = assembly.TargetType,
|
|
TargetId = assembly.TargetId,
|
|
BlueprintId = assembly.BlueprintId,
|
|
CollectionId = assembly.CollectionId,
|
|
EntryId = assembly.EntryId,
|
|
ContentNodeId = assembly.ContentNodeId,
|
|
QuestionCount = questionReferenceIds.Count,
|
|
DurationMinutes = assembly.DurationMinutes,
|
|
TotalScore = assembly.TotalScore,
|
|
ExpiresAt = assembly.DurationMinutes.HasValue
|
|
? now.AddMinutes(assembly.DurationMinutes.Value)
|
|
: null,
|
|
AccessMode = PracticeAccessMode.Free,
|
|
ConsumedFreeQuota = questionReferenceIds.Count,
|
|
AccessSnapshot = JsonSerializer.SerializeToElement(new
|
|
{
|
|
strategy = "v1_free",
|
|
requestedCount = assembly.QuestionLimit,
|
|
grantedCount = questionReferenceIds.Count
|
|
}),
|
|
Metadata = command.Metadata.ValueKind is JsonValueKind.Undefined
|
|
? JsonDefaults.Object()
|
|
: command.Metadata
|
|
};
|
|
dbContext.PracticeSessions.Add(session);
|
|
|
|
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;
|
|
dbContext.PracticeSessionQuestions.AddRange(selections.Select((selection, index) =>
|
|
new PracticeSessionQuestion
|
|
{
|
|
TenantId = actor.TenantId,
|
|
PracticeSessionId = session.Id,
|
|
QuestionReferenceId = selection.QuestionReferenceId,
|
|
QuestionOwnerTenantId = selection.QuestionOwnerTenantId,
|
|
QuestionId = selection.QuestionId,
|
|
QuestionVersionId = selection.QuestionVersionId,
|
|
Position = index,
|
|
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
|
|
{
|
|
TenantId = actor.TenantId,
|
|
UserId = actor.UserId,
|
|
PracticeSessionId = session.Id,
|
|
EventType = PracticeAccessEventType.SessionCreated,
|
|
AccessMode = PracticeAccessEventMode.Free,
|
|
RequestedCount = assembly.QuestionLimit,
|
|
GrantedCount = selections.Count,
|
|
ConsumedFreeQuota = selections.Count,
|
|
Metadata = session.AccessSnapshot
|
|
});
|
|
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return ToItem(session);
|
|
}
|
|
|
|
public async Task<PracticeSessionDetailItem> GetPracticeSessionDetailAsync(
|
|
LearningActor actor,
|
|
PracticeSessionFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var session = await GetPracticeSessionAsync(actor, filter.PracticeSessionId, cancellationToken);
|
|
var orderedQuestions = await LoadSessionQuestionItemsAsync(
|
|
actor.TenantId,
|
|
session.Id,
|
|
cancellationToken);
|
|
|
|
var answers = await dbContext.AnswerRecords
|
|
.AsNoTracking()
|
|
.Where(answer =>
|
|
answer.TenantId == actor.TenantId &&
|
|
answer.UserId == actor.UserId &&
|
|
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.Revision).First(),
|
|
session.Version));
|
|
|
|
return new PracticeSessionDetailItem(ToItem(session), orderedQuestions, answersByQuestion);
|
|
}
|
|
|
|
public async Task<PracticeSessionReportItem> SubmitPracticeSessionAsync(
|
|
LearningActor actor,
|
|
SubmitPracticeSessionCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(command.IdempotencyKey))
|
|
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.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 NpgsqlException)
|
|
{
|
|
SubmissionConflicts.Add(1);
|
|
throw new LearningValidationException("practice_submission_conflict",
|
|
"The practice session was already submitted by another request.");
|
|
}
|
|
|
|
return response;
|
|
}
|
|
}
|