feat: read numbers with units and euro/cent spellings tolerantly
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cb70a2f0a6
commit
62b3afe9c8
2 files changed
+268
-2
No files matched your search
@@ -1,5 +1,6 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace FamilyPlanner.Domain.Learning;
|
||||
|
||||
@@ -66,6 +67,131 @@ public static class LearningNumbers
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static readonly Regex NumberToken = new(@"[-−+]?\d(?:[\d.,\s ]*\d)?", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
private static readonly Regex WordToken = new(@"[\p{L}€%°²³]+", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly HashSet<string> EuroWords = new(StringComparer.Ordinal) { "€", "euro", "eur", "euros" };
|
||||
private static readonly HashSet<string> CentWords = new(StringComparer.Ordinal) { "cent", "ct", "cents" };
|
||||
|
||||
/// <summary>Einheiten und Füllwörter, die beim Lesen einer Zahl ignoriert werden (es wird nicht umgerechnet, außer bei Euro/Cent und gemischten Längen-/Gewichts-/Hohlmaßen).</summary>
|
||||
private static readonly HashSet<string> IgnoredWords = new(StringComparer.Ordinal)
|
||||
{
|
||||
"€", "euro", "eur", "euros", "cent", "ct", "cents", "%", "prozent", "°", "grad",
|
||||
"mm", "cm", "dm", "m", "km", "m²", "cm²", "km²", "m³", "cm³", "qm", "ha",
|
||||
"g", "kg", "mg", "t", "l", "ml", "cl", "dl", "hl",
|
||||
"s", "sek", "sekunde", "sekunden", "min", "minute", "minuten", "h", "std", "stunde", "stunden", "tag", "tage", "tagen", "woche", "wochen",
|
||||
"°c", "°f", "ca", "circa", "etwa", "ungefähr", "stück", "stk",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Wie <see cref="Parse"/>, liest aber zusätzlich Zahlen mit Einheiten und Schreibweisen: „1,3 €“, „12 cm“, „2 Euro 50“, „1€ 30 Cent“ (= 1,30), „50 Cent“ (0,50 und 50),
|
||||
/// „1 m 30 cm“ (1,30 und 130), „1 kg 500 g“ (1,5 und 1500). Einheiten werden nur entfernt; umgerechnet wird ausschließlich bei diesen Kombinationen, und dann kommen beide plausiblen Werte zurück.
|
||||
/// Unbekannte Wörter, mehrere unzusammenhängende Zahlen oder Rechnungen („3 oder 4“, „1 h 30 min“) ergeben eine leere Liste („unklar“).
|
||||
/// </summary>
|
||||
public static IReadOnlyList<decimal> ParseLoose(string? text)
|
||||
{
|
||||
var strict = Parse(text);
|
||||
if (strict.Count > 0 || string.IsNullOrWhiteSpace(text) || text.Length > 80)
|
||||
{
|
||||
return strict;
|
||||
}
|
||||
|
||||
var matches = NumberToken.Matches(text.Trim());
|
||||
if (matches.Count is 0 or > 2)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
// Wörter in Reihenfolge, getrennt nach „vor/zwischen/nach den Zahlen“
|
||||
var lowered = text.Trim().ToLowerInvariant();
|
||||
var numberMatches = NumberToken.Matches(lowered);
|
||||
var words = new List<(string Word, int Position)>();
|
||||
var leftover = NumberToken.Replace(lowered, " ");
|
||||
foreach (var word in WordToken.Matches(leftover).Cast<Match>())
|
||||
{
|
||||
if (!IgnoredWords.Contains(word.Value))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
words.Add((word.Value, word.Index));
|
||||
}
|
||||
|
||||
// Zeichen, die weder Wort noch harmloses Satzzeichen sind, machen die Antwort unklar.
|
||||
if (WordToken.Replace(leftover, " ").Any(c => !(char.IsWhiteSpace(c) || c is '.' or ',' or ';' or ':' or '=' or '~' or '(' or ')' or '≈' or '/')))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (numberMatches.Count == 1)
|
||||
{
|
||||
var values = Parse(numberMatches[0].Value);
|
||||
if (values.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var result = values.ToList();
|
||||
if (words.Any(w => CentWords.Contains(w.Word)) && !words.Any(w => EuroWords.Contains(w.Word)))
|
||||
{
|
||||
result.AddRange(values.Select(v => v / 100m)); // „50 Cent“: 50 (Antwort in Cent) oder 0,50 (Antwort in Euro)
|
||||
}
|
||||
|
||||
return result.Distinct().ToList();
|
||||
}
|
||||
|
||||
return ParsePair(numberMatches[0].Value, numberMatches[1].Value, WordsBetween(lowered, numberMatches[0], numberMatches[1]), WordsAfter(lowered, numberMatches[1]));
|
||||
}
|
||||
|
||||
private static List<string> WordsBetween(string lowered, Match first, Match second) =>
|
||||
[.. WordToken.Matches(lowered[(first.Index + first.Length)..second.Index]).Select(m => m.Value)];
|
||||
|
||||
private static List<string> WordsAfter(string lowered, Match last) =>
|
||||
[.. WordToken.Matches(lowered[(last.Index + last.Length)..]).Select(m => m.Value)];
|
||||
|
||||
/// <summary>Zwei Zahlen sind nur als „Hauptwert Einheit Nebenwert [Einheit]“ erlaubt (Euro/Cent, m/cm, kg/g, l/ml).</summary>
|
||||
private static List<decimal> ParsePair(string firstText, string secondText, List<string> between, List<string> after)
|
||||
{
|
||||
if (between.Count != 1 || after.Count > 1 || !int.TryParse(secondText.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var minor))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var firstValues = Parse(firstText).Where(v => v >= 0).ToList();
|
||||
if (firstValues.Count == 0 || firstText.TrimStart().StartsWith('-'))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var major = between[0];
|
||||
var tail = after.Count == 1 ? after[0] : null;
|
||||
if (EuroWords.Contains(major))
|
||||
{
|
||||
// „2 Euro 50“ (zwei Ziffern) oder „1 € 5 Cent“ (mit Cent-Angabe); Cent unter 100
|
||||
var hasCent = tail is not null && CentWords.Contains(tail);
|
||||
if ((tail is not null && !hasCent) || minor > 99 || (!hasCent && secondText.Trim().Length != 2))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return [.. firstValues.Select(v => v + (minor / 100m))];
|
||||
}
|
||||
|
||||
(string Minor, int Limit, decimal Factor)? pair = major switch
|
||||
{
|
||||
"m" => ("cm", 100, 100m),
|
||||
"kg" => ("g", 1000, 1000m),
|
||||
"l" => ("ml", 1000, 1000m),
|
||||
_ => null,
|
||||
};
|
||||
if (pair is not { } p || tail != p.Minor || minor >= p.Limit)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return [.. firstValues.SelectMany(v => new[] { v + (minor / p.Factor), (v * p.Factor) + minor })];
|
||||
}
|
||||
|
||||
/// <summary>Erster (bevorzugter) Wert oder <c>null</c>, wenn keine Zahl erkannt wurde.</summary>
|
||||
public static decimal? ParseFirst(string? text) => Parse(text) is { Count: > 0 } list ? list[0] : null;
|
||||
|
||||
@@ -140,6 +266,9 @@ public static class LearningNumbers
|
||||
/// <summary>Automatische Bewertung ohne KI (ADR-0021 Punkt 3): Zahlen und Auswahl endgültig, Freitext nur bei exakter Übereinstimmung.</summary>
|
||||
public static class LearningGrading
|
||||
{
|
||||
/// <summary>Begründung der Automatik für Zahlenantworten mit Ziffern, die sich nicht eindeutig lesen lassen.</summary>
|
||||
public const string UnclearNumberReason = "Antwort nicht eindeutig lesbar – bitte prüfen.";
|
||||
|
||||
/// <summary>Text vergleichbar machen: Unicode-Normalform, Leerraum zusammenfassen, Groß-/Kleinschreibung und Randzeichen ignorieren.</summary>
|
||||
public static string NormalizeText(string? text)
|
||||
{
|
||||
@@ -190,10 +319,13 @@ public static class LearningGrading
|
||||
|
||||
private static AutoGradeResult GradeNumber(LearningItem item, string answer)
|
||||
{
|
||||
var given = LearningNumbers.Parse(answer);
|
||||
var given = LearningNumbers.ParseLoose(answer);
|
||||
if (given.Count == 0)
|
||||
{
|
||||
return new AutoGradeResult(LearningVerdict.Wrong, 0, "Keine Zahl erkannt.");
|
||||
// Mit Ziffern, aber nicht eindeutig lesbar („5 Äpfel und 3 Birnen“, „1 h 30 min“): nie automatisch falsch, Eltern (optional die KI) entscheiden.
|
||||
return answer.Any(char.IsAsciiDigit)
|
||||
? new AutoGradeResult(LearningVerdict.Open, 0, UnclearNumberReason)
|
||||
: new AutoGradeResult(LearningVerdict.Wrong, 0, "Keine Zahl erkannt.");
|
||||
}
|
||||
|
||||
var expected = LearningNumbers.Parse(item.CorrectAnswer);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
using FamilyPlanner.Domain.Learning;
|
||||
|
||||
namespace FamilyPlanner.UnitTests.Learning;
|
||||
|
||||
/// <summary>Tolerante Auswertung von Zahlen mit Einheiten und Schreibweisen (Phase 11c): „1€ 30 Cent“ = „1,30“.</summary>
|
||||
public sealed class LearningLooseNumberTests
|
||||
{
|
||||
private static LearningItem Number(string correct, decimal? tolerance = null) =>
|
||||
new() { AnswerType = LearningAnswerType.Number, CorrectAnswer = correct, NumberTolerance = tolerance, Points = 2 };
|
||||
|
||||
public static TheoryData<string, decimal[]> Loose => new()
|
||||
{
|
||||
{ "1€ 30 Cent", [1.30m] },
|
||||
{ "1 € 30 Cent", [1.30m] },
|
||||
{ "1 Euro 30 Cent", [1.30m] },
|
||||
{ "2 Euro 50", [2.50m] },
|
||||
{ "2 euro 50", [2.50m] },
|
||||
{ "1 € 5 Cent", [1.05m] },
|
||||
{ "1,3 €", [1.3m] },
|
||||
{ "1.30 €", [1.30m] },
|
||||
{ "1,30€", [1.30m] },
|
||||
{ "1.234,50 Euro", [1234.50m] },
|
||||
{ "0,50 EUR", [0.50m] },
|
||||
{ "50 Cent", [50m, 0.50m] },
|
||||
{ "12 cm", [12m] },
|
||||
{ "12cm", [12m] },
|
||||
{ "3,5 kg", [3.5m] },
|
||||
{ "250 g", [250m] },
|
||||
{ "2 l", [2m] },
|
||||
{ "1,5 L", [1.5m] },
|
||||
{ "500 ml", [500m] },
|
||||
{ "45 min", [45m] },
|
||||
{ "2 h", [2m] },
|
||||
{ "1 000 kg", [1000m] },
|
||||
{ "ca. 12 m", [12m] },
|
||||
{ "etwa 7", [7m] },
|
||||
{ "25 %", [25m] },
|
||||
{ "90°", [90m] },
|
||||
{ "12 m²", [12m] },
|
||||
{ "30 km/h", [30m] },
|
||||
{ "-5 °C", [-5m] },
|
||||
{ "= 12", [12m] },
|
||||
{ "1 m 30 cm", [1.30m, 130m] },
|
||||
{ "2 kg 500 g", [2.5m, 2500m] },
|
||||
{ "1 l 250 ml", [1.25m, 1250m] },
|
||||
{ "12", [12m] }, // strenger Weg bleibt unverändert
|
||||
{ "1.234", [1.234m, 1234m] },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Loose))]
|
||||
public void Numbers_with_units_are_read(string input, decimal[] expected)
|
||||
{
|
||||
Assert.Equal(expected, LearningNumbers.ParseLoose(input));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("x")]
|
||||
[InlineData("fünf")]
|
||||
[InlineData("3 oder 4")]
|
||||
[InlineData("5 Äpfel")]
|
||||
[InlineData("1 h 30 min")] // keine Umrechnung von Zeiten
|
||||
[InlineData("1 Euro 3")] // ein Cent-Wert ohne Cent-Angabe braucht zwei Ziffern
|
||||
[InlineData("1 Euro 130")]
|
||||
[InlineData("1 € 30 kg")]
|
||||
[InlineData("1 m 130 cm")]
|
||||
[InlineData("1 cm 30 m")]
|
||||
[InlineData("1/2")]
|
||||
public void Unclear_input_yields_nothing(string? input) =>
|
||||
Assert.Empty(LearningNumbers.ParseLoose(input));
|
||||
|
||||
[Theory]
|
||||
[InlineData("1.30", "1€ 30 Cent")]
|
||||
[InlineData("1,30", "1€ 30 Cent")]
|
||||
[InlineData("1,30", "1 Euro 30")]
|
||||
[InlineData("2,5", "2 Euro 50")]
|
||||
[InlineData("1,3", "1,30 €")]
|
||||
[InlineData("1.30", "1.3 €")]
|
||||
[InlineData("12", "12 cm")]
|
||||
[InlineData("1234,5", "1.234,5 m")]
|
||||
[InlineData("0,5", "50 Cent")]
|
||||
[InlineData("50", "50 Cent")]
|
||||
[InlineData("130", "1 m 30 cm")]
|
||||
[InlineData("1,3", "1 m 30 cm")]
|
||||
public void Grading_accepts_equivalent_spellings(string correct, string answer)
|
||||
{
|
||||
var grade = LearningGrading.Grade(Number(correct), answer);
|
||||
|
||||
Assert.Equal(LearningVerdict.Correct, grade.Verdict);
|
||||
Assert.Equal(2, grade.AwardedPoints);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1.30", "1€ 40 Cent")]
|
||||
[InlineData("1.30", "1 Euro 3 Cent")]
|
||||
[InlineData("1.30", "30 Cent")]
|
||||
[InlineData("12", "13 cm")]
|
||||
public void Grading_still_rejects_wrong_values(string correct, string answer)
|
||||
{
|
||||
var grade = LearningGrading.Grade(Number(correct), answer);
|
||||
|
||||
Assert.Equal(LearningVerdict.Wrong, grade.Verdict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_tolerance_applies_to_loose_numbers()
|
||||
{
|
||||
Assert.Equal(LearningVerdict.Correct, LearningGrading.Grade(Number("12", 0.5m), "12,4 cm").Verdict);
|
||||
Assert.Equal(LearningVerdict.Wrong, LearningGrading.Grade(Number("12", 0.5m), "12,6 cm").Verdict);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("5 Äpfel und 3 Birnen")]
|
||||
[InlineData("3 oder 4")]
|
||||
[InlineData("1 h 30 min")]
|
||||
public void Answers_with_digits_but_no_clear_number_are_open_for_the_parents(string answer)
|
||||
{
|
||||
var grade = LearningGrading.Grade(Number("5"), answer);
|
||||
|
||||
Assert.Equal(LearningVerdict.Open, grade.Verdict);
|
||||
Assert.Equal(0, grade.AwardedPoints);
|
||||
Assert.Equal(LearningGrading.UnclearNumberReason, grade.Reason);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("x")]
|
||||
[InlineData("fünf")]
|
||||
[InlineData("?")]
|
||||
public void Answers_without_any_digit_stay_wrong(string answer) =>
|
||||
Assert.Equal(LearningVerdict.Wrong, LearningGrading.Grade(Number("5"), answer).Verdict);
|
||||
}
|
||||
Reference in new issue
Block a user