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; public sealed partial class LearningActivityService { 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 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 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 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() ?? 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; } public async Task GetPracticeSessionReportAsync( LearningActor actor, PracticeSessionFilter filter, CancellationToken cancellationToken = default) { if (!filter.PracticeSessionId.HasValue) throw new LearningValidationException("practice_session_id_required", "Practice session id is required."); var report = await dbContext.PracticeSessionReports .AsNoTracking() .SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.PracticeSessionId == filter.PracticeSessionId.Value, cancellationToken); if (report is null) throw new LearningResourceNotFoundException("practice_report_not_found", "Practice session report was not found."); return ToItem(report); } public async Task> GetPracticeReportsAsync( LearningActor actor, PracticeSessionFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.PracticeSessionReports .AsNoTracking() .Where(report => report.TenantId == actor.TenantId && report.UserId == actor.UserId); if (filter.BlueprintId.HasValue) query = query.Where(report => report.BlueprintId == filter.BlueprintId.Value); if (!string.IsNullOrWhiteSpace(filter.Mode)) query = query.Where(report => report.Mode == filter.Mode.Trim()); var items = await query .OrderByDescending(report => report.SubmittedAt) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken); return new LearningList(items.Select(ToItem).ToArray()); } public async Task> GetPracticeHistoryAsync( LearningActor actor, PracticeSessionFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.PracticeSessions .AsNoTracking() .Where(session => session.TenantId == actor.TenantId && session.UserId == actor.UserId); if (!string.IsNullOrWhiteSpace(filter.Mode)) query = query.Where(session => session.Mode == filter.Mode.Trim()); var rows = await query .GroupJoin( dbContext.PracticeSessionReports.AsNoTracking(), session => new { session.TenantId, PracticeSessionId = session.Id }, report => new { report.TenantId, report.PracticeSessionId }, (session, reports) => new { session, report = reports.FirstOrDefault() }) .OrderByDescending(row => row.session.FinishedAt ?? row.session.StartedAt) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken); var now = DateTimeOffset.UtcNow; var items = rows .Select(row => new PracticeHistoryItem( row.session.Id, row.session.Mode, row.session.TargetType, row.session.TargetId, row.session.BlueprintId, row.session.CollectionId, row.session.EntryId, row.session.ContentNodeId, row.session.QuestionCount, row.report?.AnsweredCount ?? 0, row.report?.CorrectCount ?? 0, row.report?.WrongCount ?? 0, row.session.StartedAt, row.session.FinishedAt, row.session.ExpiresAt, ResolvePracticeSessionHistoryStatus(row.session, now), row.report?.Id, row.report?.Score, row.report?.TotalScore, row.report?.Accuracy)) .Where(item => string.IsNullOrWhiteSpace(filter.Status) || item.Status == filter.Status.Trim()) .ToArray(); return new LearningList(items); } }