254 lines
11 KiB
C#
254 lines
11 KiB
C#
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Npgsql;
|
|
using Tiku.Application.Learning;
|
|
using Tiku.Application.Security;
|
|
using Tiku.Domain.Learning;
|
|
using Tiku.Infrastructure.Persistence;
|
|
|
|
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 idempotencyKey = command.IdempotencyKey.Trim();
|
|
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 strongRevocationService.EnsureVersionAsync(
|
|
actor, session.StrongRevocationVersion, cancellationToken);
|
|
var deliveryFacts = ReadV2GradingFacts(sessionQuestion);
|
|
var revealedSolution = DefersSolutionUntilSubmission(session.Mode)
|
|
? null
|
|
: await LoadV2SolutionAsync(sessionQuestion, cancellationToken);
|
|
|
|
var requestHash = HashAnswer(command);
|
|
var existingSubmission = await learningPersistence.AnswerRecords.AsNoTracking()
|
|
.SingleOrDefaultAsync(
|
|
item =>
|
|
item.TenantId == actor.TenantId &&
|
|
item.UserId == actor.UserId &&
|
|
item.PracticeSessionId == session.Id &&
|
|
item.IdempotencyKey == idempotencyKey,
|
|
cancellationToken);
|
|
if (existingSubmission is not null)
|
|
{
|
|
if (!string.Equals(existingSubmission.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(existingSubmission, revealedSolution);
|
|
}
|
|
|
|
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(
|
|
deliveryFacts.QuestionType,
|
|
deliveryFacts.CorrectOptionIndex,
|
|
deliveryFacts.CorrectOptionIndices,
|
|
deliveryFacts.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 = idempotencyKey,
|
|
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, revealedSolution);
|
|
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 == idempotencyKey, cancellationToken);
|
|
if (replay is not null && string.Equals(replay.RequestHash, requestHash, StringComparison.Ordinal))
|
|
{
|
|
IdempotencyReplays.Add(1);
|
|
return ToItem(replay, revealedSolution);
|
|
}
|
|
|
|
AnswerConflicts.Add(1);
|
|
throw new LearningValidationException("practice_answer_conflict",
|
|
"The answer conflicted with another client operation.");
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
private async Task<QuestionSolutionItem> LoadV2SolutionAsync(
|
|
PracticeSessionQuestion sessionQuestion,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return await tenantExecutionScope.ExecuteAsync(
|
|
new SystemScopeRequest(
|
|
sessionQuestion.QuestionOwnerTenantId,
|
|
SystemScopeCallerType.PublicQuestionBank,
|
|
nameof(AnsweringService),
|
|
"Reveal a locked V2 question solution after an accepted answer",
|
|
Guid.NewGuid().ToString("N")),
|
|
async (provider, token) =>
|
|
{
|
|
var persistence = provider.GetRequiredService<IQuestionBankPersistence>();
|
|
var revision = await persistence.QuestionRevisions.AsNoTracking().SingleOrDefaultAsync(item =>
|
|
item.TenantId == sessionQuestion.QuestionOwnerTenantId &&
|
|
item.Id == sessionQuestion.QuestionRevisionId,
|
|
token) ?? throw new LearningValidationException(
|
|
"practice_question_revision_missing", "The locked question revision is missing.");
|
|
return new QuestionSolutionItem(
|
|
revision.CorrectOptionIndex,
|
|
revision.CorrectOptionIndices,
|
|
revision.AnswerText,
|
|
revision.Explanation);
|
|
},
|
|
cancellationToken);
|
|
}
|
|
|
|
private static AnswerDeliveryFacts ReadV2GradingFacts(PracticeSessionQuestion question)
|
|
{
|
|
var snapshot = question.GradingRulesSnapshot;
|
|
if (snapshot.ValueKind != JsonValueKind.Object ||
|
|
!snapshot.TryGetProperty("version", out var version) ||
|
|
version.GetInt32() != 2)
|
|
throw new LearningValidationException(
|
|
"practice_grading_snapshot_invalid", "The V2 grading snapshot is invalid.");
|
|
var correctOptionIndex = snapshot.TryGetProperty("correctOptionIndex", out var single) &&
|
|
single.ValueKind == JsonValueKind.Number
|
|
? single.GetInt32()
|
|
: (int?)null;
|
|
var correctOptionIndices = snapshot.TryGetProperty("correctOptionIndices", out var multiple)
|
|
? multiple.Clone()
|
|
: JsonDocument.Parse("[]").RootElement.Clone();
|
|
var answerText = snapshot.TryGetProperty("answerText", out var answer) &&
|
|
answer.ValueKind == JsonValueKind.String
|
|
? answer.GetString()
|
|
: null;
|
|
return new AnswerDeliveryFacts(
|
|
question.QuestionType,
|
|
correctOptionIndex,
|
|
correctOptionIndices,
|
|
answerText);
|
|
}
|
|
|
|
private sealed record AnswerDeliveryFacts(
|
|
string QuestionType,
|
|
int? CorrectOptionIndex,
|
|
JsonElement CorrectOptionIndices,
|
|
string? AnswerText);
|
|
|
|
}
|