1794 lines
72 KiB
C#
1794 lines
72 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using System.Diagnostics.Metrics;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Tiku.Application.Learning;
|
|
using Tiku.Application.QuestionBanks;
|
|
using Tiku.Application.Security;
|
|
using Tiku.Domain.Common;
|
|
using Tiku.Domain.Content;
|
|
using Tiku.Domain.Learning;
|
|
using Tiku.Domain.QuestionBanks;
|
|
using Tiku.Infrastructure.Persistence;
|
|
|
|
namespace Tiku.Infrastructure.Learning;
|
|
|
|
public sealed class LearningActivityService(
|
|
TikuDbContext dbContext,
|
|
IQuestionReferenceService questionReferenceService,
|
|
IPublicQuestionAccessPolicy publicQuestionAccessPolicy,
|
|
ITenantExecutionScope tenantExecutionScope,
|
|
ILogger<LearningActivityService> logger) : ILearningActivityService
|
|
{
|
|
private const int DefaultLimit = 100;
|
|
private const int MaxLimit = 500;
|
|
private static readonly Meter LearningMeter = new("Tiku.Learning");
|
|
private static readonly Counter<long> IdempotencyReplays = LearningMeter.CreateCounter<long>("tiku.learning.idempotency.replays");
|
|
private static readonly Counter<long> AnswerConflicts = LearningMeter.CreateCounter<long>("tiku.learning.answer.conflicts");
|
|
private static readonly Counter<long> SubmissionConflicts = LearningMeter.CreateCounter<long>("tiku.learning.submission.conflicts");
|
|
private static readonly Counter<long> ScoringFailures = LearningMeter.CreateCounter<long>("tiku.learning.scoring.failures");
|
|
|
|
public async Task<LearningStatsItem> GetStatsAsync(
|
|
LearningActor actor,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var answers = dbContext.AnswerRecords.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId &&
|
|
item.UserId == actor.UserId &&
|
|
item.IsCurrent &&
|
|
item.GradingStatus != AnswerGradingStatus.PendingReview &&
|
|
item.GradingStatus != AnswerGradingStatus.LegacyUnverified);
|
|
return new LearningStatsItem(
|
|
await answers.CountAsync(cancellationToken),
|
|
await answers.CountAsync(item => item.IsCorrect == true, cancellationToken),
|
|
await dbContext.WrongQuestions.AsNoTracking().CountAsync(
|
|
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.ResolvedAt == null,
|
|
cancellationToken),
|
|
await dbContext.FavoriteQuestions.AsNoTracking().CountAsync(
|
|
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
|
|
cancellationToken),
|
|
await dbContext.UserWordFavorites.AsNoTracking().CountAsync(
|
|
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
|
|
cancellationToken),
|
|
await dbContext.UserWordProgress.AsNoTracking().CountAsync(
|
|
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
|
|
cancellationToken),
|
|
await dbContext.PracticeSessions.AsNoTracking().CountAsync(
|
|
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
|
|
cancellationToken),
|
|
await dbContext.PracticeSessionReports.AsNoTracking().CountAsync(
|
|
item => item.TenantId == actor.TenantId &&
|
|
item.UserId == actor.UserId &&
|
|
item.Status == PracticeReportStatus.Final,
|
|
cancellationToken));
|
|
}
|
|
|
|
public async Task<LearningList<LearningTrendItem>> GetTrendAsync(
|
|
LearningActor actor,
|
|
LearningLimitFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var since = DateTimeOffset.UtcNow.AddDays(-ResolveLimit(filter.Limit));
|
|
var rows = await dbContext.AnswerRecords.AsNoTracking()
|
|
.Where(item =>
|
|
item.TenantId == actor.TenantId &&
|
|
item.UserId == actor.UserId &&
|
|
item.IsCurrent &&
|
|
item.GradingStatus != AnswerGradingStatus.PendingReview &&
|
|
item.GradingStatus != AnswerGradingStatus.LegacyUnverified &&
|
|
item.AnsweredAt >= since)
|
|
.Select(item => new { item.AnsweredAt, item.IsCorrect })
|
|
.ToArrayAsync(cancellationToken);
|
|
var items = rows
|
|
.GroupBy(item => DateOnly.FromDateTime(item.AnsweredAt.UtcDateTime))
|
|
.OrderBy(group => group.Key)
|
|
.Select(group => new LearningTrendItem(
|
|
group.Key,
|
|
group.Count(),
|
|
group.Count(item => item.IsCorrect == true),
|
|
group.Count(item => item.IsCorrect == false)))
|
|
.ToArray();
|
|
return new LearningList<LearningTrendItem>(items);
|
|
}
|
|
|
|
public async Task<LearningLeaderboardResult> GetLeaderboardAsync(
|
|
LearningActor actor,
|
|
LearningLimitFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var rows = await dbContext.AnswerRecords.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId &&
|
|
item.IsCurrent &&
|
|
item.GradingStatus != AnswerGradingStatus.PendingReview &&
|
|
item.GradingStatus != AnswerGradingStatus.LegacyUnverified)
|
|
.GroupBy(item => item.UserId)
|
|
.Select(group => new
|
|
{
|
|
UserId = group.Key,
|
|
AnswerCount = group.Count(),
|
|
CorrectCount = group.Count(item => item.IsCorrect == true),
|
|
WrongCount = group.Count(item => item.IsCorrect == false)
|
|
})
|
|
.OrderByDescending(item => item.CorrectCount)
|
|
.ThenByDescending(item => item.AnswerCount)
|
|
.Take(ResolveLimit(filter.Limit))
|
|
.ToArrayAsync(cancellationToken);
|
|
var userIds = rows.Select(item => item.UserId).ToArray();
|
|
var names = await dbContext.Users.AsNoTracking()
|
|
.Where(item => userIds.Contains(item.Id))
|
|
.ToDictionaryAsync(item => item.Id, item => item.Name ?? item.Phone, cancellationToken);
|
|
var items = rows
|
|
.Select(item => new LearningLeaderboardItem(
|
|
item.UserId,
|
|
names.GetValueOrDefault(item.UserId),
|
|
item.AnswerCount,
|
|
item.CorrectCount,
|
|
item.WrongCount,
|
|
item.AnswerCount == 0 ? 0 : decimal.Round((decimal)item.CorrectCount / item.AnswerCount, 4)))
|
|
.ToArray();
|
|
return new LearningLeaderboardResult(
|
|
"correct_count",
|
|
"all",
|
|
items,
|
|
items.FirstOrDefault(item => item.UserId == actor.UserId),
|
|
DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
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 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<AnswerRecordItem>()
|
|
?? 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 Npgsql.PostgresException postgresException &&
|
|
postgresException.SqlState == Npgsql.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<AnswerRecordItem>()
|
|
?? 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;
|
|
}
|
|
|
|
public async Task<LearningList<FavoriteQuestionItem>> GetFavoriteQuestionsAsync(
|
|
LearningActor actor,
|
|
LearningLimitFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var items = await dbContext.FavoriteQuestions
|
|
.AsNoTracking()
|
|
.Where(item =>
|
|
item.TenantId == actor.TenantId &&
|
|
item.UserId == actor.UserId)
|
|
.OrderByDescending(item => item.CreatedAt)
|
|
.Take(ResolveLimit(filter.Limit))
|
|
.Select(item => new FavoriteQuestionItem(
|
|
item.QuestionReferenceId,
|
|
new QuestionLocator(
|
|
item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform,
|
|
item.QuestionId),
|
|
item.CreatedAt))
|
|
.ToArrayAsync(cancellationToken);
|
|
|
|
return new LearningList<FavoriteQuestionItem>(items);
|
|
}
|
|
|
|
public async Task<LearningActionResult> ToggleFavoriteQuestionAsync(
|
|
LearningActor actor,
|
|
QuestionActionCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var reference = await questionReferenceService.ResolveAsync(
|
|
actor.TenantId,
|
|
actor.UserId,
|
|
command.Locator,
|
|
cancellationToken);
|
|
|
|
var favorite = command.Favorite ?? true;
|
|
var item = await dbContext.FavoriteQuestions.FindAsync(
|
|
[actor.TenantId, actor.UserId, reference.Id],
|
|
cancellationToken);
|
|
|
|
if (favorite)
|
|
{
|
|
if (item is null)
|
|
{
|
|
dbContext.FavoriteQuestions.Add(new FavoriteQuestion
|
|
{
|
|
TenantId = actor.TenantId,
|
|
UserId = actor.UserId,
|
|
QuestionReferenceId = reference.Id,
|
|
QuestionOwnerTenantId = reference.QuestionOwnerTenantId,
|
|
QuestionId = reference.QuestionId,
|
|
Source = reference.Source.ToString().ToLowerInvariant(),
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
}
|
|
}
|
|
else if (item is not null)
|
|
{
|
|
dbContext.FavoriteQuestions.Remove(item);
|
|
}
|
|
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return new LearningActionResult(true, favorite);
|
|
}
|
|
|
|
public async Task<LearningList<WrongQuestionItem>> GetWrongQuestionsAsync(
|
|
LearningActor actor,
|
|
LearningLimitFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var query = dbContext.WrongQuestions
|
|
.AsNoTracking()
|
|
.Where(item =>
|
|
item.TenantId == actor.TenantId &&
|
|
item.UserId == actor.UserId);
|
|
|
|
if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
query = query.Where(item => item.ResolvedAt == null);
|
|
}
|
|
|
|
var items = await query
|
|
.OrderByDescending(item => item.LastWrongAt)
|
|
.Take(ResolveLimit(filter.Limit))
|
|
.Select(item => new WrongQuestionItem(
|
|
item.QuestionReferenceId,
|
|
new QuestionLocator(
|
|
item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform,
|
|
item.QuestionId),
|
|
item.WrongCount,
|
|
item.LastWrongAt,
|
|
item.ResolvedAt))
|
|
.ToArrayAsync(cancellationToken);
|
|
|
|
return new LearningList<WrongQuestionItem>(items);
|
|
}
|
|
|
|
public async Task<LearningActionResult> ResolveWrongQuestionAsync(
|
|
LearningActor actor,
|
|
QuestionActionCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var reference = await questionReferenceService.ResolveAsync(
|
|
actor.TenantId,
|
|
actor.UserId,
|
|
command.Locator,
|
|
cancellationToken);
|
|
var item = await dbContext.WrongQuestions.FindAsync(
|
|
[actor.TenantId, actor.UserId, reference.Id],
|
|
cancellationToken);
|
|
|
|
if (item is null)
|
|
{
|
|
throw new LearningResourceNotFoundException("wrong_question_not_found", "Wrong question was not found.");
|
|
}
|
|
|
|
item.ResolvedAt = DateTimeOffset.UtcNow;
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return new LearningActionResult(true);
|
|
}
|
|
|
|
public async Task<WrongQuestionReviewPlan> GetWrongQuestionReviewPlanAsync(
|
|
LearningActor actor,
|
|
LearningLimitFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var items = await dbContext.WrongQuestions.AsNoTracking()
|
|
.Where(item =>
|
|
item.TenantId == actor.TenantId &&
|
|
item.UserId == actor.UserId &&
|
|
item.ResolvedAt == null)
|
|
.OrderByDescending(item => item.WrongCount)
|
|
.ThenBy(item => item.LastWrongAt)
|
|
.Take(ResolveLimit(filter.Limit))
|
|
.Select(item => new WrongQuestionReviewPlanItem(
|
|
item.QuestionReferenceId,
|
|
new QuestionLocator(
|
|
item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform,
|
|
item.QuestionId),
|
|
item.WrongCount,
|
|
item.LastWrongAt))
|
|
.ToArrayAsync(cancellationToken);
|
|
return new WrongQuestionReviewPlan(
|
|
items,
|
|
JsonSerializer.SerializeToElement(new
|
|
{
|
|
mode = "wrong_review",
|
|
questionCount = items.Length,
|
|
recommendedEndpoint = "/api/learning/practice-sessions"
|
|
}));
|
|
}
|
|
|
|
public async Task<LearningList<WordProgressItem>> GetWordProgressAsync(
|
|
LearningActor actor,
|
|
LearningLimitFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var query = dbContext.UserWordProgress
|
|
.AsNoTracking()
|
|
.Where(item =>
|
|
item.TenantId == actor.TenantId &&
|
|
item.UserId == actor.UserId);
|
|
|
|
if (filter.UnitId.HasValue)
|
|
{
|
|
query = query.Where(item => dbContext.VocabularyWords.Any(word =>
|
|
word.TenantId == actor.TenantId &&
|
|
word.Id == item.WordId &&
|
|
word.UnitId == filter.UnitId.Value));
|
|
}
|
|
|
|
if (TryParseWordProgressStatus(filter.Status, out var status))
|
|
{
|
|
query = query.Where(item => item.Status == status);
|
|
}
|
|
|
|
var items = await query
|
|
.OrderBy(item => item.NextReviewAt == null)
|
|
.ThenBy(item => item.NextReviewAt)
|
|
.ThenByDescending(item => item.UpdatedAt)
|
|
.Take(ResolveLimit(filter.Limit))
|
|
.Select(item => new WordProgressItem(
|
|
item.WordId,
|
|
item.Status,
|
|
item.CorrectCount,
|
|
item.WrongCount,
|
|
item.LastReviewAt,
|
|
item.NextReviewAt,
|
|
item.ReviewCount,
|
|
item.CorrectStreak,
|
|
item.LastResult,
|
|
item.DueLevel,
|
|
item.Metadata))
|
|
.ToArrayAsync(cancellationToken);
|
|
|
|
return new LearningList<WordProgressItem>(items);
|
|
}
|
|
|
|
public async Task<WordProgressItem> UpdateWordProgressAsync(
|
|
LearningActor actor,
|
|
WordProgressCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await EnsureWordExistsAsync(actor.TenantId, command.WordId, cancellationToken);
|
|
|
|
var now = DateTimeOffset.UtcNow;
|
|
var item = await dbContext.UserWordProgress
|
|
.SingleOrDefaultAsync(
|
|
progress =>
|
|
progress.TenantId == actor.TenantId &&
|
|
progress.UserId == actor.UserId &&
|
|
progress.WordId == command.WordId,
|
|
cancellationToken);
|
|
|
|
if (item is null)
|
|
{
|
|
item = new UserWordProgress
|
|
{
|
|
TenantId = actor.TenantId,
|
|
UserId = actor.UserId,
|
|
WordId = command.WordId
|
|
};
|
|
dbContext.UserWordProgress.Add(item);
|
|
}
|
|
|
|
if (TryParseWordProgressStatus(command.Status, out var status))
|
|
{
|
|
item.Status = status;
|
|
}
|
|
else if (string.IsNullOrWhiteSpace(command.Status))
|
|
{
|
|
item.Status = WordProgressStatus.Learning;
|
|
}
|
|
else
|
|
{
|
|
throw new LearningValidationException("invalid_word_status", "Word progress status is invalid.");
|
|
}
|
|
|
|
var correctDelta = command.CorrectDelta ?? 0;
|
|
var wrongDelta = command.WrongDelta ?? 0;
|
|
item.CorrectCount += correctDelta;
|
|
item.WrongCount += wrongDelta;
|
|
item.ReviewCount += correctDelta + wrongDelta;
|
|
item.CorrectStreak = wrongDelta > 0
|
|
? 0
|
|
: item.CorrectStreak + correctDelta;
|
|
item.LastResult = wrongDelta > 0
|
|
? WordReviewResult.Wrong
|
|
: correctDelta > 0
|
|
? WordReviewResult.Correct
|
|
: item.LastResult;
|
|
item.LastReviewAt = correctDelta + wrongDelta > 0 ? now : item.LastReviewAt;
|
|
item.NextReviewAt = command.NextReviewAt ?? item.NextReviewAt;
|
|
item.DueLevel = item.Status == WordProgressStatus.Mastered
|
|
? WordDueLevel.Mastered
|
|
: item.WrongCount > 0 && item.CorrectStreak == 0
|
|
? WordDueLevel.Again
|
|
: item.DueLevel;
|
|
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return ToItem(item);
|
|
}
|
|
|
|
public async Task<WordReviewPlan> GetWordReviewPlanAsync(
|
|
LearningActor actor,
|
|
LearningLimitFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var query = dbContext.UserWordProgress.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
|
|
if (filter.UnitId.HasValue)
|
|
{
|
|
query = query.Where(item => dbContext.VocabularyWords.Any(word =>
|
|
word.TenantId == actor.TenantId &&
|
|
word.Id == item.WordId &&
|
|
word.UnitId == filter.UnitId.Value));
|
|
}
|
|
|
|
var items = await query
|
|
.Where(item => item.NextReviewAt == null || item.NextReviewAt <= now)
|
|
.OrderBy(item => item.NextReviewAt == null)
|
|
.ThenBy(item => item.NextReviewAt)
|
|
.ThenByDescending(item => item.WrongCount)
|
|
.Take(ResolveLimit(filter.Limit))
|
|
.Select(item => new WordReviewPlanItem(
|
|
item.WordId,
|
|
item.Status,
|
|
item.NextReviewAt,
|
|
item.CorrectCount,
|
|
item.WrongCount,
|
|
item.DueLevel))
|
|
.ToArrayAsync(cancellationToken);
|
|
return new WordReviewPlan(
|
|
items,
|
|
JsonSerializer.SerializeToElement(new
|
|
{
|
|
mode = "word_review",
|
|
wordCount = items.Length
|
|
}));
|
|
}
|
|
|
|
public Task<WordProgressItem> ReviewWordAsync(
|
|
LearningActor actor,
|
|
WordReviewCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var correct = string.Equals(command.Result, "correct", StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(command.Result, "known", StringComparison.OrdinalIgnoreCase);
|
|
return UpdateWordProgressAsync(
|
|
actor,
|
|
new WordProgressCommand(
|
|
command.WordId,
|
|
correct ? "Reviewing" : "Learning",
|
|
correct ? 1 : 0,
|
|
correct ? 0 : 1,
|
|
command.NextReviewAt ?? DateTimeOffset.UtcNow.AddDays(correct ? 2 : 1)),
|
|
cancellationToken);
|
|
}
|
|
|
|
public async Task<WordStatsItem> GetWordStatsAsync(
|
|
LearningActor actor,
|
|
LearningLimitFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var query = dbContext.UserWordProgress.AsNoTracking()
|
|
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
|
|
if (filter.UnitId.HasValue)
|
|
{
|
|
query = query.Where(item => dbContext.VocabularyWords.Any(word =>
|
|
word.TenantId == actor.TenantId &&
|
|
word.Id == item.WordId &&
|
|
word.UnitId == filter.UnitId.Value));
|
|
}
|
|
|
|
return new WordStatsItem(
|
|
await query.CountAsync(cancellationToken),
|
|
await query.CountAsync(item => item.Status == WordProgressStatus.New, cancellationToken),
|
|
await query.CountAsync(item => item.Status == WordProgressStatus.Learning, cancellationToken),
|
|
await query.CountAsync(item => item.Status == WordProgressStatus.Reviewing, cancellationToken),
|
|
await query.CountAsync(item => item.Status == WordProgressStatus.Mastered, cancellationToken),
|
|
await query.CountAsync(item => item.NextReviewAt == null || item.NextReviewAt <= now, cancellationToken),
|
|
await dbContext.UserWordFavorites.AsNoTracking().CountAsync(
|
|
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
|
|
cancellationToken));
|
|
}
|
|
|
|
public async Task<LearningList<FavoriteWordItem>> GetFavoriteWordsAsync(
|
|
LearningActor actor,
|
|
LearningLimitFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var query = dbContext.UserWordFavorites
|
|
.AsNoTracking()
|
|
.Where(item =>
|
|
item.TenantId == actor.TenantId &&
|
|
item.UserId == actor.UserId);
|
|
|
|
if (filter.UnitId.HasValue)
|
|
{
|
|
query = query.Where(item => dbContext.VocabularyWords.Any(word =>
|
|
word.TenantId == actor.TenantId &&
|
|
word.Id == item.WordId &&
|
|
word.UnitId == filter.UnitId.Value));
|
|
}
|
|
|
|
var items = await query
|
|
.OrderByDescending(item => item.FavoritedAt ?? item.CreatedAt)
|
|
.Take(ResolveLimit(filter.Limit))
|
|
.Select(item => new FavoriteWordItem(
|
|
item.WordId,
|
|
item.Note,
|
|
item.FavoritedAt))
|
|
.ToArrayAsync(cancellationToken);
|
|
|
|
return new LearningList<FavoriteWordItem>(items);
|
|
}
|
|
|
|
public async Task<LearningActionResult> ToggleFavoriteWordAsync(
|
|
LearningActor actor,
|
|
FavoriteWordCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await EnsureWordExistsAsync(actor.TenantId, command.WordId, cancellationToken);
|
|
|
|
var favorite = command.Favorite ?? true;
|
|
var item = await dbContext.UserWordFavorites
|
|
.SingleOrDefaultAsync(
|
|
favoriteWord =>
|
|
favoriteWord.TenantId == actor.TenantId &&
|
|
favoriteWord.UserId == actor.UserId &&
|
|
favoriteWord.WordId == command.WordId,
|
|
cancellationToken);
|
|
|
|
if (favorite)
|
|
{
|
|
if (item is null)
|
|
{
|
|
item = new UserWordFavorite
|
|
{
|
|
TenantId = actor.TenantId,
|
|
UserId = actor.UserId,
|
|
WordId = command.WordId
|
|
};
|
|
dbContext.UserWordFavorites.Add(item);
|
|
}
|
|
|
|
item.Note = command.Note ?? item.Note;
|
|
item.FavoritedAt ??= DateTimeOffset.UtcNow;
|
|
}
|
|
else if (item is not null)
|
|
{
|
|
dbContext.UserWordFavorites.Remove(item);
|
|
}
|
|
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return new LearningActionResult(true, favorite);
|
|
}
|
|
|
|
public async Task<PracticeSessionItem> 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<PracticeSessionDetailItem> 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<PracticeSessionReportItem> 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<PracticeSessionReportItem>()
|
|
?? 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 Npgsql.NpgsqlException)
|
|
{
|
|
SubmissionConflicts.Add(1);
|
|
throw new LearningValidationException("practice_submission_conflict", "The practice session was already submitted by another request.");
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
public async Task<PracticeSessionReportItem> 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<LearningList<PracticeSessionReportItem>> 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<PracticeSessionReportItem>(items.Select(ToItem).ToArray());
|
|
}
|
|
|
|
public async Task<LearningList<PracticeHistoryItem>> 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<PracticeHistoryItem>(items);
|
|
}
|
|
|
|
private async Task<PracticeAssembly> BuildPracticeAssemblyAsync(
|
|
Guid tenantId,
|
|
PracticeSessionCommand command,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var mode = NormalizeMode(command.Mode);
|
|
var assembly = new PracticeAssembly(
|
|
mode,
|
|
command.TargetType,
|
|
command.TargetId,
|
|
command.BlueprintId,
|
|
command.CollectionId,
|
|
command.EntryId,
|
|
command.ContentNodeId,
|
|
Math.Clamp(command.QuestionLimit ?? 100, 1, MaxLimit),
|
|
command.DurationMinutes,
|
|
command.TotalScore);
|
|
|
|
if (!command.BlueprintId.HasValue)
|
|
{
|
|
return assembly;
|
|
}
|
|
|
|
var blueprint = await dbContext.PracticeBlueprints
|
|
.AsNoTracking()
|
|
.SingleOrDefaultAsync(
|
|
item =>
|
|
item.TenantId == tenantId &&
|
|
item.Id == command.BlueprintId.Value &&
|
|
item.Status == ContentStatus.Active,
|
|
cancellationToken);
|
|
|
|
if (blueprint is null)
|
|
{
|
|
throw new LearningResourceNotFoundException("practice_blueprint_not_found", "Practice blueprint was not found.");
|
|
}
|
|
|
|
return assembly with
|
|
{
|
|
Mode = NormalizeMode(blueprint.Mode.ToString()),
|
|
TargetType = command.TargetType ?? "blueprint",
|
|
TargetId = command.TargetId ?? blueprint.Id,
|
|
CollectionId = command.CollectionId ?? blueprint.CollectionId,
|
|
EntryId = command.EntryId ?? blueprint.EntryId,
|
|
ContentNodeId = command.ContentNodeId ?? blueprint.NodeId,
|
|
QuestionLimit = Math.Clamp(command.QuestionLimit ?? blueprint.QuestionLimit ?? 100, 1, MaxLimit),
|
|
DurationMinutes = command.DurationMinutes ?? blueprint.DurationMinutes,
|
|
TotalScore = command.TotalScore ?? blueprint.TotalScore
|
|
};
|
|
}
|
|
|
|
private async Task<List<Guid>> CollectQuestionReferenceIdsAsync(
|
|
LearningActor actor,
|
|
PracticeAssembly assembly,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (assembly.Mode == "wrong_review")
|
|
{
|
|
return await dbContext.WrongQuestions
|
|
.AsNoTracking()
|
|
.Where(item =>
|
|
item.TenantId == actor.TenantId &&
|
|
item.UserId == actor.UserId &&
|
|
item.ResolvedAt == null)
|
|
.OrderByDescending(item => item.WrongCount)
|
|
.ThenBy(item => item.LastWrongAt)
|
|
.Take(assembly.QuestionLimit)
|
|
.Select(item => item.QuestionReferenceId)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
if (assembly.Mode == "favorite_review")
|
|
{
|
|
return await dbContext.FavoriteQuestions
|
|
.AsNoTracking()
|
|
.Where(item =>
|
|
item.TenantId == actor.TenantId &&
|
|
item.UserId == actor.UserId)
|
|
.OrderByDescending(item => item.CreatedAt)
|
|
.Take(assembly.QuestionLimit)
|
|
.Select(item => item.QuestionReferenceId)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
if (assembly.CollectionId.HasValue)
|
|
{
|
|
return await dbContext.QuestionCollectionItems
|
|
.AsNoTracking()
|
|
.Where(item =>
|
|
item.TenantId == actor.TenantId &&
|
|
item.CollectionId == assembly.CollectionId.Value)
|
|
.OrderBy(item => item.SortOrder)
|
|
.Take(assembly.QuestionLimit)
|
|
.Select(item => item.QuestionReferenceId)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
var query = dbContext.Questions
|
|
.AsNoTracking()
|
|
.Where(question =>
|
|
question.TenantId == actor.TenantId &&
|
|
question.Status == QuestionStatus.Published);
|
|
|
|
if (assembly.ContentNodeId.HasValue)
|
|
{
|
|
query = query.Where(question => question.ContentNodeId == assembly.ContentNodeId.Value);
|
|
}
|
|
else if (assembly.EntryId.HasValue)
|
|
{
|
|
query = query.Where(question => question.EntryId == assembly.EntryId.Value);
|
|
}
|
|
else if (assembly.TargetId.HasValue && !string.IsNullOrWhiteSpace(assembly.TargetType))
|
|
{
|
|
query = ApplyLegacyTargetFilter(query, assembly.TargetType, assembly.TargetId.Value);
|
|
}
|
|
else
|
|
{
|
|
throw new LearningValidationException("practice_target_required", "Practice target is required.");
|
|
}
|
|
|
|
var questionIds = await query
|
|
.OrderBy(question => question.CreatedAt)
|
|
.Take(assembly.QuestionLimit)
|
|
.Select(question => question.Id)
|
|
.ToListAsync(cancellationToken);
|
|
var referenceIds = new List<Guid>(questionIds.Count);
|
|
foreach (var questionId in questionIds)
|
|
{
|
|
var reference = await questionReferenceService.ResolveAsync(
|
|
actor.TenantId,
|
|
actor.UserId,
|
|
new QuestionLocator(QuestionSource.Tenant, questionId),
|
|
cancellationToken);
|
|
referenceIds.Add(reference.Id);
|
|
}
|
|
|
|
return referenceIds;
|
|
}
|
|
|
|
private static IQueryable<Question> ApplyLegacyTargetFilter(
|
|
IQueryable<Question> query,
|
|
string? targetType,
|
|
Guid targetId)
|
|
{
|
|
return NormalizeEnumValue(targetType) switch
|
|
{
|
|
"subject" => query.Where(question => question.SubjectId == targetId),
|
|
"category" => query.Where(question => question.CategoryId == targetId),
|
|
"node" => query.Where(question => question.NodeId == targetId),
|
|
"questionbank" => query.Where(question => question.QuestionBankId == targetId),
|
|
"contentnode" => query.Where(question => question.ContentNodeId == targetId),
|
|
"entry" => query.Where(question => question.EntryId == targetId),
|
|
_ => query.Where(_ => false)
|
|
};
|
|
}
|
|
|
|
private async Task<IReadOnlyList<QuestionSelection>> LoadQuestionSelectionsAsync(
|
|
Guid tenantId,
|
|
IReadOnlyCollection<Guid> questionReferenceIds,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var rows = await tenantExecutionScope.ExecuteAsync(
|
|
new SystemScopeRequest(
|
|
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityService),
|
|
"Lock published question versions for a new practice session", Guid.NewGuid().ToString("N")),
|
|
async (provider, token) =>
|
|
{
|
|
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
|
return await (
|
|
from reference in systemDbContext.TenantQuestionReferences.AsNoTracking()
|
|
join question in systemDbContext.Questions.AsNoTracking()
|
|
on new { TenantId = reference.QuestionOwnerTenantId, Id = reference.QuestionId }
|
|
equals new { question.TenantId, question.Id }
|
|
join version in systemDbContext.QuestionVersions.AsNoTracking()
|
|
on new
|
|
{
|
|
TenantId = reference.QuestionOwnerTenantId,
|
|
reference.QuestionId,
|
|
Id = question.CurrentVersionId
|
|
}
|
|
equals new
|
|
{
|
|
version.TenantId,
|
|
version.QuestionId,
|
|
Id = (Guid?)version.Id
|
|
}
|
|
where reference.TenantId == tenantId &&
|
|
questionReferenceIds.Contains(reference.Id) &&
|
|
question.Status == QuestionStatus.Published
|
|
select new QuestionSelection(
|
|
reference.Id,
|
|
reference.QuestionOwnerTenantId,
|
|
reference.QuestionId,
|
|
version.Id,
|
|
question.Type,
|
|
question.TypeLabel,
|
|
question.Difficulty,
|
|
question.Tags,
|
|
version.Content,
|
|
version.Options,
|
|
version.CorrectOptionIndex,
|
|
version.CorrectOptionIndices,
|
|
version.AnswerText,
|
|
version.Explanation))
|
|
.ToArrayAsync(token);
|
|
},
|
|
cancellationToken);
|
|
|
|
var byReference = rows.ToDictionary(row => row.QuestionReferenceId);
|
|
if (byReference.Count != questionReferenceIds.Distinct().Count())
|
|
{
|
|
throw new LearningValidationException(
|
|
"practice_question_unavailable",
|
|
"One or more practice questions have no published version.");
|
|
}
|
|
|
|
return questionReferenceIds.Select(referenceId => byReference[referenceId]).ToArray();
|
|
}
|
|
|
|
private Task<PracticeSessionQuestionItem[]> LoadSessionQuestionItemsAsync(
|
|
Guid tenantId,
|
|
Guid practiceSessionId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return tenantExecutionScope.ExecuteAsync(
|
|
new SystemScopeRequest(
|
|
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityService),
|
|
"Read locked question versions for a tenant practice session", Guid.NewGuid().ToString("N")),
|
|
async (provider, token) =>
|
|
{
|
|
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
|
return await (
|
|
from sessionQuestion in systemDbContext.PracticeSessionQuestions.AsNoTracking()
|
|
where sessionQuestion.TenantId == tenantId &&
|
|
sessionQuestion.PracticeSessionId == practiceSessionId
|
|
orderby sessionQuestion.Position
|
|
select new PracticeSessionQuestionItem(
|
|
sessionQuestion.Id,
|
|
sessionQuestion.QuestionReferenceId,
|
|
new QuestionLocator(
|
|
sessionQuestion.QuestionOwnerTenantId == tenantId
|
|
? QuestionSource.Tenant
|
|
: QuestionSource.Platform,
|
|
sessionQuestion.QuestionId),
|
|
sessionQuestion.QuestionId,
|
|
sessionQuestion.QuestionType,
|
|
sessionQuestion.TypeLabelSnapshot,
|
|
sessionQuestion.DifficultySnapshot,
|
|
sessionQuestion.TagsSnapshot,
|
|
sessionQuestion.QuestionVersionId,
|
|
sessionQuestion.ContentSnapshot,
|
|
sessionQuestion.OptionsSnapshot))
|
|
.ToArrayAsync(token);
|
|
},
|
|
cancellationToken);
|
|
}
|
|
|
|
private async Task<PracticeSession> GetPracticeSessionAsync(
|
|
LearningActor actor,
|
|
Guid? practiceSessionId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!practiceSessionId.HasValue)
|
|
{
|
|
throw new LearningValidationException("practice_session_id_required", "Practice session id is required.");
|
|
}
|
|
|
|
var session = await dbContext.PracticeSessions
|
|
.SingleOrDefaultAsync(
|
|
item =>
|
|
item.TenantId == actor.TenantId &&
|
|
item.UserId == actor.UserId &&
|
|
item.Id == practiceSessionId.Value,
|
|
cancellationToken);
|
|
|
|
if (session is null)
|
|
{
|
|
throw new LearningResourceNotFoundException("practice_session_not_found", "Practice session was not found.");
|
|
}
|
|
|
|
return session;
|
|
}
|
|
|
|
private async Task<PracticeSessionReport> BuildPracticeSessionReportAsync(
|
|
LearningActor actor,
|
|
PracticeSession session,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var sessionQuestions = await dbContext.PracticeSessionQuestions.AsNoTracking()
|
|
.Where(item =>
|
|
item.TenantId == actor.TenantId &&
|
|
item.PracticeSessionId == session.Id)
|
|
.OrderBy(item => item.Position)
|
|
.ToArrayAsync(cancellationToken);
|
|
if (sessionQuestions.Length == 0)
|
|
{
|
|
throw new LearningValidationException("practice_session_empty", "Practice session has no question snapshot.");
|
|
}
|
|
|
|
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 latestAnswers = answers.ToDictionary(answer => answer.SessionQuestionId);
|
|
var totalQuestions = sessionQuestions.Length;
|
|
var answeredCount = sessionQuestions.Count(question => latestAnswers.ContainsKey(question.Id));
|
|
var correctCount = sessionQuestions.Count(question =>
|
|
latestAnswers.TryGetValue(question.Id, out var answer) &&
|
|
answer.GradingStatus == AnswerGradingStatus.Correct);
|
|
var wrongCount = sessionQuestions.Count(question =>
|
|
latestAnswers.TryGetValue(question.Id, out var answer) &&
|
|
answer.GradingStatus == AnswerGradingStatus.Incorrect);
|
|
var pendingReviewCount = sessionQuestions.Count(question =>
|
|
latestAnswers.TryGetValue(question.Id, out var answer) &&
|
|
answer.GradingStatus == AnswerGradingStatus.PendingReview);
|
|
var unansweredCount = Math.Max(0, totalQuestions - answeredCount);
|
|
var totalScore = sessionQuestions.Sum(question => question.Score ?? 1);
|
|
var score = Math.Round(answers.Sum(answer => answer.AwardedScore ?? 0), 2);
|
|
var objectivelyGradedCount = correctCount + wrongCount;
|
|
var accuracy = objectivelyGradedCount == 0
|
|
? 0
|
|
: Math.Round((decimal)correctCount / objectivelyGradedCount, 4);
|
|
var isFinal = pendingReviewCount == 0;
|
|
var submittedAt = DateTimeOffset.UtcNow;
|
|
var durationSeconds = Math.Max(0, (int)(submittedAt - session.StartedAt).TotalSeconds);
|
|
var wrongQuestionIds = sessionQuestions
|
|
.Where(question =>
|
|
latestAnswers.TryGetValue(question.Id, out var answer) &&
|
|
answer.GradingStatus == AnswerGradingStatus.Incorrect)
|
|
.Select(question => question.QuestionReferenceId)
|
|
.ToArray();
|
|
var questionResults = sessionQuestions
|
|
.Select(question =>
|
|
{
|
|
latestAnswers.TryGetValue(question.Id, out var answer);
|
|
return new
|
|
{
|
|
sessionQuestionId = question.Id,
|
|
questionReferenceId = question.QuestionReferenceId,
|
|
questionId = question.QuestionId,
|
|
source = question.QuestionOwnerTenantId == actor.TenantId ? "tenant" : "platform",
|
|
answered = answer is not null,
|
|
gradingStatus = answer?.GradingStatus.ToString(),
|
|
isCorrect = isFinal ? answer?.IsCorrect : null,
|
|
score = answer?.AwardedScore,
|
|
totalScore = question.Score ?? 1,
|
|
answeredAt = answer?.AnsweredAt,
|
|
correctOptionIndex = isFinal ? question.CorrectOptionIndexSnapshot : null,
|
|
correctOptionIndices = isFinal ? question.CorrectOptionIndicesSnapshot : JsonDefaults.Array(),
|
|
answerText = isFinal ? question.AnswerTextSnapshot : null,
|
|
explanation = isFinal ? question.ExplanationSnapshot : null
|
|
};
|
|
})
|
|
.ToArray();
|
|
var sectionStats = new[]
|
|
{
|
|
new
|
|
{
|
|
key = "default",
|
|
title = "默认",
|
|
questionCount = totalQuestions,
|
|
answeredCount,
|
|
correctCount,
|
|
wrongCount,
|
|
unansweredCount,
|
|
score,
|
|
totalScore,
|
|
accuracy,
|
|
pendingReviewCount,
|
|
sortOrder = 0
|
|
}
|
|
};
|
|
|
|
var report = new PracticeSessionReport
|
|
{
|
|
TenantId = actor.TenantId,
|
|
UserId = actor.UserId,
|
|
PracticeSessionId = session.Id,
|
|
BlueprintId = session.BlueprintId,
|
|
CollectionId = session.CollectionId,
|
|
Mode = session.Mode,
|
|
TotalQuestions = totalQuestions,
|
|
AnsweredCount = answeredCount,
|
|
CorrectCount = correctCount,
|
|
WrongCount = wrongCount,
|
|
UnansweredCount = unansweredCount,
|
|
Score = score,
|
|
TotalScore = totalScore,
|
|
Accuracy = accuracy,
|
|
DurationSeconds = durationSeconds,
|
|
StartedAt = session.StartedAt,
|
|
SubmittedAt = submittedAt,
|
|
Status = isFinal ? PracticeReportStatus.Final : PracticeReportStatus.PendingReview,
|
|
Version = 1,
|
|
IsFinal = isFinal,
|
|
PendingReviewCount = pendingReviewCount,
|
|
ScoringVersion = 1,
|
|
SectionStats = JsonSerializer.SerializeToElement(sectionStats),
|
|
QuestionResults = JsonSerializer.SerializeToElement(questionResults),
|
|
WrongQuestionIds = JsonSerializer.SerializeToElement(wrongQuestionIds),
|
|
Metadata = JsonSerializer.SerializeToElement(new
|
|
{
|
|
scoringVersion = 1
|
|
})
|
|
};
|
|
dbContext.PracticeSessionReports.Add(report);
|
|
dbContext.PracticeSessionReportSections.Add(new PracticeSessionReportSection
|
|
{
|
|
TenantId = actor.TenantId,
|
|
ReportId = report.Id,
|
|
PracticeSessionId = session.Id,
|
|
SectionKey = "default",
|
|
SectionName = "默认",
|
|
QuestionCount = totalQuestions,
|
|
AnsweredCount = answeredCount,
|
|
CorrectCount = correctCount,
|
|
WrongCount = wrongCount,
|
|
UnansweredCount = unansweredCount,
|
|
Score = score,
|
|
TotalScore = totalScore,
|
|
Accuracy = accuracy,
|
|
SortOrder = 0
|
|
});
|
|
|
|
foreach (var question in sessionQuestions.Where(question =>
|
|
latestAnswers.TryGetValue(question.Id, out var answer) &&
|
|
answer.GradingStatus == AnswerGradingStatus.Incorrect))
|
|
{
|
|
var wrongQuestion = await dbContext.WrongQuestions.FindAsync(
|
|
[actor.TenantId, actor.UserId, question.QuestionReferenceId], cancellationToken);
|
|
if (wrongQuestion is null)
|
|
{
|
|
dbContext.WrongQuestions.Add(new WrongQuestion
|
|
{
|
|
TenantId = actor.TenantId,
|
|
UserId = actor.UserId,
|
|
QuestionReferenceId = question.QuestionReferenceId,
|
|
QuestionOwnerTenantId = question.QuestionOwnerTenantId,
|
|
QuestionId = question.QuestionId,
|
|
WrongCount = 1,
|
|
LastWrongAt = submittedAt
|
|
});
|
|
}
|
|
else
|
|
{
|
|
wrongQuestion.WrongCount++;
|
|
wrongQuestion.LastWrongAt = submittedAt;
|
|
wrongQuestion.ResolvedAt = null;
|
|
}
|
|
}
|
|
|
|
return report;
|
|
}
|
|
|
|
private async Task EnsureQuestionExistsAsync(
|
|
Guid tenantId,
|
|
Guid questionId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var exists = await dbContext.Questions.AnyAsync(
|
|
question =>
|
|
question.TenantId == tenantId &&
|
|
question.Id == questionId &&
|
|
question.Status == QuestionStatus.Published,
|
|
cancellationToken);
|
|
|
|
if (!exists)
|
|
{
|
|
throw new LearningResourceNotFoundException("question_not_found", "Question was not found.");
|
|
}
|
|
}
|
|
|
|
private async Task EnsureWordExistsAsync(
|
|
Guid tenantId,
|
|
Guid wordId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var exists = await dbContext.VocabularyWords.AnyAsync(
|
|
word =>
|
|
word.TenantId == tenantId &&
|
|
word.Id == wordId &&
|
|
word.IsActive,
|
|
cancellationToken);
|
|
|
|
if (!exists)
|
|
{
|
|
throw new LearningResourceNotFoundException("word_not_found", "Word was not found.");
|
|
}
|
|
}
|
|
|
|
private static AnswerRecordItem ToItem(AnswerRecord record, long sessionVersion)
|
|
{
|
|
return new AnswerRecordItem(
|
|
record.Id,
|
|
record.SessionQuestionId,
|
|
record.PracticeSessionId,
|
|
record.SelectedOptions,
|
|
record.AnswerText,
|
|
record.GradingStatus == AnswerGradingStatus.PendingReview ? "pending_review" : "accepted",
|
|
record.Revision,
|
|
record.ClientSequence,
|
|
sessionVersion,
|
|
record.AnsweredAt);
|
|
}
|
|
|
|
private static WordProgressItem ToItem(UserWordProgress item)
|
|
{
|
|
return new WordProgressItem(
|
|
item.WordId,
|
|
item.Status,
|
|
item.CorrectCount,
|
|
item.WrongCount,
|
|
item.LastReviewAt,
|
|
item.NextReviewAt,
|
|
item.ReviewCount,
|
|
item.CorrectStreak,
|
|
item.LastResult,
|
|
item.DueLevel,
|
|
item.Metadata);
|
|
}
|
|
|
|
private static PracticeSessionItem ToItem(PracticeSession item)
|
|
{
|
|
return new PracticeSessionItem(
|
|
item.Id,
|
|
item.Mode,
|
|
item.TargetType,
|
|
item.TargetId,
|
|
item.BlueprintId,
|
|
item.CollectionId,
|
|
item.EntryId,
|
|
item.ContentNodeId,
|
|
item.QuestionCount,
|
|
item.DurationMinutes,
|
|
item.TotalScore,
|
|
item.AccessMode,
|
|
item.AccessEntitlementId,
|
|
item.ConsumedFreeQuota,
|
|
item.AccessSnapshot,
|
|
item.StartedAt,
|
|
item.FinishedAt,
|
|
item.ExpiresAt,
|
|
item.Metadata,
|
|
item.Status,
|
|
item.Version,
|
|
item.LastClientSequence);
|
|
}
|
|
|
|
private static PracticeSessionReportItem ToItem(PracticeSessionReport item)
|
|
{
|
|
return new PracticeSessionReportItem(
|
|
item.Id,
|
|
item.PracticeSessionId,
|
|
item.BlueprintId,
|
|
item.CollectionId,
|
|
item.Mode,
|
|
item.TotalQuestions,
|
|
item.AnsweredCount,
|
|
item.CorrectCount,
|
|
item.WrongCount,
|
|
item.UnansweredCount,
|
|
item.Score,
|
|
item.TotalScore,
|
|
item.Accuracy,
|
|
item.DurationSeconds,
|
|
item.StartedAt,
|
|
item.SubmittedAt,
|
|
item.Status,
|
|
item.Version,
|
|
item.IsFinal,
|
|
item.PendingReviewCount,
|
|
item.ScoringVersion,
|
|
item.SectionStats,
|
|
item.QuestionResults,
|
|
item.WrongQuestionIds,
|
|
item.Metadata);
|
|
}
|
|
|
|
private static List<Guid> ReadGuidArray(JsonElement value)
|
|
{
|
|
if (value.ValueKind is not JsonValueKind.Array)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
return value.EnumerateArray()
|
|
.Select(item => item.ValueKind == JsonValueKind.String && Guid.TryParse(item.GetString(), out var id)
|
|
? (Guid?)id
|
|
: null)
|
|
.Where(id => id.HasValue)
|
|
.Select(id => id!.Value)
|
|
.ToList();
|
|
}
|
|
|
|
private static void EnsureAnswerSessionState(
|
|
PracticeSession session,
|
|
SubmitAnswerCommand command)
|
|
{
|
|
if (session.Status != PracticeSessionStatus.Active)
|
|
{
|
|
throw new LearningValidationException("practice_session_not_active", "Only an active practice session accepts answers.");
|
|
}
|
|
if (session.Version != command.ExpectedSessionVersion)
|
|
{
|
|
throw new LearningValidationException("practice_session_version_conflict", "The practice session changed. Reload it before answering.");
|
|
}
|
|
if (command.ClientSequence <= session.LastClientSequence)
|
|
{
|
|
throw new LearningValidationException("practice_client_sequence_conflict", "Client sequence must increase within a practice session.");
|
|
}
|
|
}
|
|
|
|
private static JsonElement BuildGradingRules(QuestionSelection selection) =>
|
|
JsonSerializer.SerializeToElement(new
|
|
{
|
|
version = 1,
|
|
normalization = selection.QuestionType.Equals("fill_blank", StringComparison.OrdinalIgnoreCase)
|
|
? "nfkc_trim_casefold_whitespace"
|
|
: "exact"
|
|
});
|
|
|
|
private static string HashAnswer(SubmitAnswerCommand command) => Hash(JsonSerializer.Serialize(new
|
|
{
|
|
command.SessionQuestionId,
|
|
command.ExpectedSessionVersion,
|
|
command.ClientSequence,
|
|
selectedOptionIndices = command.SelectedOptionIndices?.Distinct().Order().ToArray() ?? [],
|
|
answerText = command.AnswerText?.Trim()
|
|
}));
|
|
|
|
private static string HashSubmission(SubmitPracticeSessionCommand command) => Hash(JsonSerializer.Serialize(new
|
|
{
|
|
command.PracticeSessionId,
|
|
command.ExpectedSessionVersion
|
|
}));
|
|
|
|
private static string Hash(string value) =>
|
|
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
|
|
|
private static string ResolvePracticeSessionHistoryStatus(PracticeSession session, DateTimeOffset now)
|
|
{
|
|
if (session.Status is PracticeSessionStatus.Submitted or PracticeSessionStatus.PendingReview)
|
|
{
|
|
return "finished";
|
|
}
|
|
|
|
if (session.Status == PracticeSessionStatus.Expired ||
|
|
session.ExpiresAt.HasValue && session.ExpiresAt.Value <= now)
|
|
{
|
|
return "expired";
|
|
}
|
|
|
|
return "active";
|
|
}
|
|
|
|
private static string NormalizeMode(string? mode)
|
|
{
|
|
return NormalizeEnumValue(mode) switch
|
|
{
|
|
"sequential" => "sequential",
|
|
"random" => "random",
|
|
"mockexam" => "mock_exam",
|
|
"paper" => "paper",
|
|
"wrongreview" => "wrong_review",
|
|
"favoritereview" => "favorite_review",
|
|
_ => "chapter"
|
|
};
|
|
}
|
|
|
|
private static bool TryParseWordProgressStatus(string? value, out WordProgressStatus status)
|
|
{
|
|
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out status);
|
|
}
|
|
|
|
private static int ResolveLimit(int? limit)
|
|
{
|
|
return Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit);
|
|
}
|
|
|
|
private static string? NormalizeEnumValue(string? value)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? null
|
|
: value.Replace("_", string.Empty, StringComparison.Ordinal)
|
|
.Replace("-", string.Empty, StringComparison.Ordinal);
|
|
}
|
|
|
|
private sealed record PracticeAssembly(
|
|
string Mode,
|
|
string? TargetType,
|
|
Guid? TargetId,
|
|
Guid? BlueprintId,
|
|
Guid? CollectionId,
|
|
Guid? EntryId,
|
|
Guid? ContentNodeId,
|
|
int QuestionLimit,
|
|
int? DurationMinutes,
|
|
decimal? TotalScore);
|
|
|
|
private sealed record QuestionSelection(
|
|
Guid QuestionReferenceId,
|
|
Guid QuestionOwnerTenantId,
|
|
Guid QuestionId,
|
|
Guid QuestionVersionId,
|
|
string QuestionType,
|
|
string? TypeLabel,
|
|
int? Difficulty,
|
|
JsonElement Tags,
|
|
string? Content,
|
|
JsonElement Options,
|
|
int? CorrectOptionIndex,
|
|
JsonElement CorrectOptionIndices,
|
|
string? AnswerText,
|
|
string? Explanation);
|
|
}
|
|
|
|
public class LearningException(string code, string message) : Exception(message)
|
|
{
|
|
public string Code { get; } = code;
|
|
}
|
|
|
|
public sealed class LearningResourceNotFoundException(string code, string message) : LearningException(code, message);
|
|
|
|
public sealed class LearningValidationException(string code, string message) : LearningException(code, message);
|