Files
tiku-backend.net/Tiku.Infrastructure/Learning/Answering/AnsweringService.cs

190 lines
8.3 KiB
C#

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<AnswerRecordItem> 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 learningPersistence.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 learningPersistence.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.");
await learningAccessService.EnsureStrongRevocationVersionAsync(
actor, session.StrongRevocationVersion, cancellationToken);
var deliveryVersion = await LoadDeliveryVersionAsync(actor.TenantId, sessionQuestion, cancellationToken);
var requestHash = HashAnswer(command);
var existingAnswer = await learningPersistence.AnswerRecords.AsNoTracking().SingleOrDefaultAsync(
item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.PracticeSessionId == session.Id &&
item.IdempotencyKey == command.IdempotencyKey,
cancellationToken);
if (existingAnswer is not null)
{
if (!string.Equals(existingAnswer.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 ToItem(
existingAnswer,
DefersSolutionUntilSubmission(session.Mode) ? null : ToSolutionItem(deliveryVersion));
}
if (session.ExpiresAt.HasValue && session.ExpiresAt <= now)
{
session.Status = PracticeSessionStatus.Expired;
session.Version++;
await unitOfWork.SaveChangesAsync(cancellationToken);
throw new LearningValidationException("practice_session_expired", "The practice session has expired.");
}
EnsureAnswerSessionState(session, command);
var current = await learningPersistence.CurrentAnswers.SingleOrDefaultAsync(answer =>
answer.TenantId == actor.TenantId &&
answer.UserId == actor.UserId &&
answer.PracticeSessionId == session.Id &&
answer.SessionQuestionId == sessionQuestion.Id, cancellationToken);
if (current is not null && command.ClientSequence <= current.ClientSequence)
throw new LearningValidationException(
"practice_client_sequence_conflict",
"Client sequence must increase for a revised answer.");
var selectedIndices = command.SelectedOptionIndices?.Distinct().Order().ToArray() ?? [];
var score = sessionQuestion.Score ?? 1;
QuestionGradingResult grading;
try
{
grading = QuestionGrader.Grade(new QuestionGradingInput(
deliveryVersion.QuestionType,
deliveryVersion.CorrectOptionIndex,
deliveryVersion.CorrectOptionIndices,
deliveryVersion.AnswerText,
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,
AnsweredAt = now,
CreatedAt = now
};
learningPersistence.AnswerRecords.Add(record);
if (current is null)
{
current = new CurrentAnswer
{
TenantId = actor.TenantId,
UserId = actor.UserId,
PracticeSessionId = session.Id,
SessionQuestionId = sessionQuestion.Id,
AnswerRecordId = record.Id,
Revision = record.Revision,
ClientSequence = record.ClientSequence,
UpdatedAt = now
};
learningPersistence.CurrentAnswers.Add(current);
}
else
{
current.AnswerRecordId = record.Id;
current.Revision = record.Revision;
current.ClientSequence = record.ClientSequence;
current.Version++;
current.UpdatedAt = now;
}
var response = ToItem(
record,
DefersSolutionUntilSubmission(session.Mode) ? null : ToSolutionItem(deliveryVersion));
try
{
await unitOfWork.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)
{
unitOfWork.ChangeTracker.Clear();
var replay = await learningPersistence.AnswerRecords.AsNoTracking().SingleOrDefaultAsync(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.PracticeSessionId == session.Id &&
item.IdempotencyKey == command.IdempotencyKey, cancellationToken);
if (replay is not null && string.Equals(replay.RequestHash, requestHash, StringComparison.Ordinal))
{
IdempotencyReplays.Add(1);
return ToItem(
replay,
DefersSolutionUntilSubmission(session.Mode) ? null : ToSolutionItem(deliveryVersion));
}
AnswerConflicts.Add(1);
throw new LearningValidationException("practice_answer_conflict",
"The answer conflicted with another client operation.");
}
return response;
}
}