161 lines
5.6 KiB
C#
161 lines
5.6 KiB
C#
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();
|
|
}
|
|
} |