feat(learning): make practice scoring authoritative

This commit is contained in:
2026-08-03 09:44:57 +08:00
parent a9a8f0ab9f
commit 6bdfee6807
19 changed files with 22684 additions and 168 deletions

View File

@@ -93,7 +93,7 @@ public interface ILearningActivityService
Task<PracticeSessionReportItem> SubmitPracticeSessionAsync(
LearningActor actor,
PracticeSessionFilter filter,
SubmitPracticeSessionCommand command,
CancellationToken cancellationToken = default);
Task<PracticeSessionReportItem> GetPracticeSessionReportAsync(

View File

@@ -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,

View 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();
}
}