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 access = await ResolvePracticeAccessAsync(actor, assembly, 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 sessionExpiry = assembly.DurationMinutes.HasValue ? now.AddMinutes(Math.Min(240, assembly.DurationMinutes.Value + 15)) : now.AddHours(2); 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 = sessionExpiry, AccessMode = access.AccessMode, AccessEntitlementId = access.EntitlementId, AccessClassAssignmentId = access.ClassAssignmentId, ConsumedFreeQuota = 0, AccessGrantVersion = access.Snapshot.GrantVersion, StrongRevocationVersion = access.Snapshot.StrongRevocationVersion, AuthorizationExpiresAt = sessionExpiry, AccessSnapshot = JsonSerializer.SerializeToElement(new { strategy = "compiled_access_v1", contentSliceId = access.ContentSliceId, businessLineId = access.Snapshot.BusinessLineId, regionAccessStrategy = access.Snapshot.RegionAccessStrategy, targetRegionId = access.Snapshot.TargetRegionId, grantVersion = access.Snapshot.GrantVersion, contentVersion = access.Snapshot.ContentVersion, strongRevocationVersion = access.Snapshot.StrongRevocationVersion, 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, GradingRulesSnapshot = BuildGradingRules(selection), SnapshotVersion = 1 })); learningPersistence.PracticeAccessEvents.Add(new PracticeAccessEvent { TenantId = actor.TenantId, UserId = actor.UserId, PracticeSessionId = session.Id, EventType = PracticeAccessEventType.SessionCreated, AccessMode = access.AccessMode switch { PracticeAccessMode.Package => PracticeAccessEventMode.Package, PracticeAccessMode.ClassAssignment => PracticeAccessEventMode.ClassAssignment, PracticeAccessMode.Staff => PracticeAccessEventMode.Staff, PracticeAccessMode.Svip => PracticeAccessEventMode.Svip, _ => PracticeAccessEventMode.Free }, RequestedCount = assembly.QuestionLimit, GrantedCount = selections.Count, ConsumedFreeQuota = 0, EntitlementId = access.EntitlementId, 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); await learningAccessService.EnsureStrongRevocationVersionAsync( actor, session.StrongRevocationVersion, 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 && learningPersistence.CurrentAnswers.Any(current => current.TenantId == answer.TenantId && current.AnswerRecordId == answer.Id)) .ToArrayAsync(cancellationToken); var answersByQuestion = answers .GroupBy(answer => answer.SessionQuestionId) .ToDictionary( group => group.Key, group => ToItem( group.OrderByDescending(answer => answer.Revision).First())); var revealAll = session.Status is PracticeSessionStatus.Submitted or PracticeSessionStatus.PendingReview; var revealAnswered = !DefersSolutionUntilSubmission(session.Mode); var revealQuestionIds = revealAll ? orderedQuestions.Select(item => item.SessionQuestionId).ToArray() : revealAnswered ? answersByQuestion.Keys.ToArray() : []; var solutions = revealQuestionIds.Length == 0 ? new Dictionary() : await LoadSolutionsAsync(actor.TenantId, session.Id, revealQuestionIds, cancellationToken); return new PracticeSessionDetailItem(ToItem(session), orderedQuestions, answersByQuestion, solutions); } private async Task> LoadSolutionsAsync( Guid tenantId, Guid sessionId, IReadOnlyCollection revealQuestionIds, CancellationToken cancellationToken) { var sessionQuestions = await learningPersistence.PracticeSessionQuestions.AsNoTracking() .Where(item => item.TenantId == tenantId && item.PracticeSessionId == sessionId && revealQuestionIds.Contains(item.Id)) .ToArrayAsync(cancellationToken); var versions = await LoadDeliveryVersionsAsync(tenantId, sessionQuestions, cancellationToken); return sessionQuestions.ToDictionary( item => item.Id, item => ToSolutionItem(versions[item.QuestionVersionId])); } 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); await learningAccessService.EnsureStrongRevocationVersionAsync( actor, session.StrongRevocationVersion, 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; } private async Task ResolvePracticeAccessAsync( LearningActor actor, PracticeAssembly assembly, CancellationToken cancellationToken) { if (assembly.BlueprintId.HasValue) return await learningAccessService.EnsureResourceAccessAsync( actor, LearningContentResourceType.Blueprint, assembly.BlueprintId.Value, cancellationToken); if (assembly.CollectionId.HasValue) return await learningAccessService.EnsureResourceAccessAsync( actor, LearningContentResourceType.Collection, assembly.CollectionId.Value, cancellationToken); if (assembly.ContentNodeId.HasValue) return await learningAccessService.EnsureResourceAccessAsync( actor, LearningContentResourceType.ContentNode, assembly.ContentNodeId.Value, cancellationToken); if (assembly.EntryId.HasValue) return await learningAccessService.EnsureResourceAccessAsync( actor, LearningContentResourceType.ContentEntry, assembly.EntryId.Value, cancellationToken); if (assembly.TargetId.HasValue && string.Equals(assembly.TargetType?.Replace("_", string.Empty), "questionbank", StringComparison.OrdinalIgnoreCase)) return await learningAccessService.EnsureResourceAccessAsync( actor, LearningContentResourceType.QuestionBank, assembly.TargetId.Value, cancellationToken); var snapshot = await learningAccessService.GetSnapshotAsync(actor, cancellationToken); if (snapshot.ContentSliceIds.Count == 0) throw new LearningAccessException( "learning_content_not_entitled", "The current student has no active learning content grant."); return new LearningResourceAccessDecision( snapshot.ContentSliceIds.First(), snapshot.ClassAssignmentIds.Count > 0 ? PracticeAccessMode.ClassAssignment : PracticeAccessMode.Package, null, snapshot.ClassAssignmentIds.FirstOrDefault() is { } assignmentId && assignmentId != Guid.Empty ? assignmentId : null, snapshot); } }