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 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 questionBankPersistence.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 }; learningPersistence.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; learningPersistence.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 })); learningPersistence.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 unitOfWork.SaveChangesAsync(cancellationToken); return ToItem(session); } public async Task 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 learningPersistence.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 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 unitOfWork.Database.BeginTransactionAsync(cancellationToken); var session = await GetPracticeSessionAsync(actor, command.PracticeSessionId, cancellationToken); var requestHash = HashSubmission(command); var existingOperation = await learningPersistence.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() ?? 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 unitOfWork.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); learningPersistence.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 unitOfWork.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; } }