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:
JannisandClaude Sonnet 5.5 committed 2026-10-10 18:47:17 +02:00
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);
}