feat(learning): make practice scoring authoritative
This commit is contained in:
@@ -93,7 +93,7 @@ public interface ILearningActivityService
|
||||
|
||||
Task<PracticeSessionReportItem> SubmitPracticeSessionAsync(
|
||||
LearningActor actor,
|
||||
PracticeSessionFilter filter,
|
||||
SubmitPracticeSessionCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PracticeSessionReportItem> GetPracticeSessionReportAsync(
|
||||
|
||||
@@ -11,9 +11,16 @@ public sealed record LearningList<TItem>(IReadOnlyCollection<TItem> Items);
|
||||
|
||||
public sealed record SubmitAnswerCommand(
|
||||
Guid SessionQuestionId,
|
||||
IReadOnlyCollection<string>? SelectedOptions,
|
||||
string? AnswerText,
|
||||
bool? SelfJudgedCorrect);
|
||||
long ExpectedSessionVersion,
|
||||
long ClientSequence,
|
||||
string IdempotencyKey,
|
||||
IReadOnlyCollection<int>? SelectedOptionIndices,
|
||||
string? AnswerText);
|
||||
|
||||
public sealed record SubmitPracticeSessionCommand(
|
||||
Guid PracticeSessionId,
|
||||
long ExpectedSessionVersion,
|
||||
string IdempotencyKey);
|
||||
|
||||
public sealed record QuestionActionCommand(QuestionLocator Locator, bool? Favorite);
|
||||
|
||||
@@ -112,10 +119,13 @@ public sealed record AnswerRecordItem(
|
||||
Guid Id,
|
||||
Guid SessionQuestionId,
|
||||
Guid PracticeSessionId,
|
||||
JsonElement SelectedOptions,
|
||||
JsonElement SelectedOptionIndices,
|
||||
string? AnswerText,
|
||||
bool? IsCorrect,
|
||||
DateTimeOffset AnsweredAt);
|
||||
string Status,
|
||||
int Revision,
|
||||
long ClientSequence,
|
||||
long SessionVersion,
|
||||
DateTimeOffset AcceptedAt);
|
||||
|
||||
public sealed record FavoriteQuestionItem(
|
||||
Guid QuestionReferenceId,
|
||||
@@ -169,7 +179,9 @@ public sealed record PracticeSessionItem(
|
||||
DateTimeOffset? FinishedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
JsonElement Metadata,
|
||||
string Status);
|
||||
PracticeSessionStatus Status,
|
||||
long Version,
|
||||
long LastClientSequence);
|
||||
|
||||
public sealed record PracticeSessionDetailItem(
|
||||
PracticeSessionItem Session,
|
||||
@@ -187,8 +199,7 @@ public sealed record PracticeSessionQuestionItem(
|
||||
JsonElement Tags,
|
||||
Guid? VersionId,
|
||||
string? Content,
|
||||
JsonElement Options,
|
||||
string? Explanation);
|
||||
JsonElement Options);
|
||||
|
||||
public sealed record PracticeSessionReportItem(
|
||||
Guid Id,
|
||||
@@ -207,6 +218,11 @@ public sealed record PracticeSessionReportItem(
|
||||
int DurationSeconds,
|
||||
DateTimeOffset? StartedAt,
|
||||
DateTimeOffset SubmittedAt,
|
||||
PracticeReportStatus Status,
|
||||
int Version,
|
||||
bool IsFinal,
|
||||
int PendingReviewCount,
|
||||
int ScoringVersion,
|
||||
JsonElement SectionStats,
|
||||
JsonElement QuestionResults,
|
||||
JsonElement WrongQuestionIds,
|
||||
|
||||
180
Tiku.Application/Learning/QuestionGrading.cs
Normal file
180
Tiku.Application/Learning/QuestionGrading.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Tiku.Domain.Learning;
|
||||
|
||||
namespace Tiku.Application.Learning;
|
||||
|
||||
public sealed record QuestionGradingInput(
|
||||
string QuestionType,
|
||||
int? CorrectOptionIndex,
|
||||
JsonElement CorrectOptionIndices,
|
||||
string? CorrectAnswerText,
|
||||
JsonElement GradingRules,
|
||||
IReadOnlyCollection<int>? SelectedOptionIndices,
|
||||
string? AnswerText,
|
||||
decimal Score);
|
||||
|
||||
public sealed record QuestionGradingResult(
|
||||
AnswerGradingStatus Status,
|
||||
bool? IsCorrect,
|
||||
decimal? AwardedScore);
|
||||
|
||||
public static class QuestionGrader
|
||||
{
|
||||
private static readonly IReadOnlySet<string> SubjectiveTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"short_answer", "reading", "programming"
|
||||
};
|
||||
|
||||
public static QuestionGradingResult Grade(QuestionGradingInput input)
|
||||
{
|
||||
var type = input.QuestionType.Trim().ToLowerInvariant();
|
||||
if (SubjectiveTypes.Contains(type))
|
||||
{
|
||||
return new QuestionGradingResult(AnswerGradingStatus.PendingReview, null, null);
|
||||
}
|
||||
|
||||
var correct = type switch
|
||||
{
|
||||
"choice" => GradeChoice(input),
|
||||
"multiple_choice" => GradeMultipleChoice(input),
|
||||
"true_false" => GradeTrueFalse(input),
|
||||
"fill_blank" => GradeFillBlank(input),
|
||||
_ => throw new InvalidOperationException($"Unsupported question type '{input.QuestionType}'.")
|
||||
};
|
||||
return new QuestionGradingResult(
|
||||
correct ? AnswerGradingStatus.Correct : AnswerGradingStatus.Incorrect,
|
||||
correct,
|
||||
correct ? input.Score : 0);
|
||||
}
|
||||
|
||||
public static bool HasValidAuthoritativeAnswer(
|
||||
string questionType,
|
||||
int? correctOptionIndex,
|
||||
JsonElement correctOptionIndices,
|
||||
string? answerText)
|
||||
{
|
||||
return questionType.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"choice" => correctOptionIndex is >= 0,
|
||||
"multiple_choice" => ReadIndices(correctOptionIndices).Count > 0,
|
||||
"true_false" => TryNormalizeBoolean(answerText, out _),
|
||||
"fill_blank" => !string.IsNullOrWhiteSpace(answerText),
|
||||
"short_answer" or "reading" or "programming" => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static bool GradeChoice(QuestionGradingInput input)
|
||||
{
|
||||
var selected = input.SelectedOptionIndices?.Distinct().ToArray() ?? [];
|
||||
return input.CorrectOptionIndex.HasValue &&
|
||||
selected.Length == 1 &&
|
||||
selected[0] == input.CorrectOptionIndex.Value;
|
||||
}
|
||||
|
||||
private static bool GradeMultipleChoice(QuestionGradingInput input)
|
||||
{
|
||||
var expected = ReadIndices(input.CorrectOptionIndices);
|
||||
var selected = (input.SelectedOptionIndices ?? []).Distinct().Order().ToArray();
|
||||
return expected.Count > 0 && expected.SequenceEqual(selected);
|
||||
}
|
||||
|
||||
private static bool GradeTrueFalse(QuestionGradingInput input)
|
||||
{
|
||||
return TryNormalizeBoolean(input.CorrectAnswerText, out var expected) &&
|
||||
TryNormalizeBoolean(input.AnswerText, out var actual) &&
|
||||
expected == actual;
|
||||
}
|
||||
|
||||
private static bool GradeFillBlank(QuestionGradingInput input)
|
||||
{
|
||||
var accepted = new HashSet<string>(StringComparer.Ordinal);
|
||||
AddNormalized(accepted, input.CorrectAnswerText);
|
||||
if (input.GradingRules.ValueKind == JsonValueKind.Object &&
|
||||
input.GradingRules.TryGetProperty("acceptedAnswers", out var alternatives) &&
|
||||
alternatives.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var alternative in alternatives.EnumerateArray())
|
||||
{
|
||||
if (alternative.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
AddNormalized(accepted, alternative.GetString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accepted.Count > 0 && accepted.Contains(NormalizeText(input.AnswerText));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<int> ReadIndices(JsonElement value)
|
||||
{
|
||||
if (value.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.EnumerateArray()
|
||||
.Where(item => item.TryGetInt32(out _))
|
||||
.Select(item => item.GetInt32())
|
||||
.Distinct()
|
||||
.Order()
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool TryNormalizeBoolean(string? value, out bool result)
|
||||
{
|
||||
switch (NormalizeText(value))
|
||||
{
|
||||
case "true" or "1" or "yes" or "正确" or "对":
|
||||
result = true;
|
||||
return true;
|
||||
case "false" or "0" or "no" or "错误" or "错":
|
||||
result = false;
|
||||
return true;
|
||||
default:
|
||||
result = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddNormalized(ISet<string> values, string? value)
|
||||
{
|
||||
var normalized = NormalizeText(value);
|
||||
if (normalized.Length > 0)
|
||||
{
|
||||
values.Add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeText(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = value.Normalize(NormalizationForm.FormKC).Trim().ToLower(CultureInfo.InvariantCulture);
|
||||
var builder = new StringBuilder(normalized.Length);
|
||||
var previousWhitespace = false;
|
||||
foreach (var character in normalized)
|
||||
{
|
||||
if (char.IsWhiteSpace(character))
|
||||
{
|
||||
if (!previousWhitespace)
|
||||
{
|
||||
builder.Append(' ');
|
||||
previousWhitespace = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(character);
|
||||
previousWhitespace = false;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user