using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Npgsql; using Tiku.Application.Learning; using Tiku.Domain.Learning; namespace Tiku.Infrastructure.Learning; internal sealed class AnsweringService(LearningServiceDependencies dependencies) : LearningActivityServiceBase(dependencies), IAnsweringService { public async Task SubmitAnswerAsync( LearningActor actor, 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.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == command.SessionQuestionId, cancellationToken); if (sessionQuestion is null) throw new LearningResourceNotFoundException( "session_question_not_found", "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() ?? 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(selectedIndices), AnswerText = command.AnswerText, 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); session.Version++; session.LastClientSequence = command.ClientSequence; var response = ToItem(record, session.Version); dbContext.LearningOperationIdempotencies.Add(new LearningOperationIdempotency { TenantId = actor.TenantId, UserId = actor.UserId, PracticeSessionId = session.Id, OperationType = "answer", IdempotencyKey = command.IdempotencyKey.Trim(), RequestHash = requestHash, ResponseSnapshot = JsonSerializer.SerializeToElement(response), CompletedAt = now }); 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 PostgresException postgresException && postgresException.SqlState == 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)) { IdempotencyReplays.Add(1); return replay.ResponseSnapshot.Deserialize() ?? 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."); } return response; } }