feat: add safe arithmetic expression evaluator for checking AI math solutions

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 14:36:46 +02:00
1 parent e1c1492fc5
commit f40818269e
2 files changed
+219

No files matched your search

@@ -0,0 +1,167 @@
using System.Globalization;
namespace FamilyPlanner.Domain.Learning;
/// <summary>
/// Rechnet einfache Rechenausdrücke nach (Phase 10b): Ganz- und Dezimalzahlen (Punkt oder Komma), <c>+ - * /</c>, die Schulzeichen
/// <c>× · ÷ :</c> und Klammern. Bewusst klein und streng: nichts anderes wird ausgewertet (keine Namen, Funktionen, Potenzen), Länge und
/// Verschachtelung sind begrenzt, Division durch null und Überläufe liefern „nicht berechenbar“ statt einer Ausnahme.
/// Die Eingabe stammt von einer KI und ist nie vertrauenswürdig.
/// </summary>
public static class LearningExpression
{
public const int MaxLength = 120;
private const int MaxDepth = 12;
/// <summary>Wert des Ausdrucks oder <c>null</c>, wenn er leer, ungültig oder nicht berechenbar ist.</summary>
public static decimal? TryEvaluate(string? expression)
{
if (string.IsNullOrWhiteSpace(expression) || expression.Length > MaxLength)
{
return null;
}
try
{
var parser = new Parser(expression);
return parser.Parse();
}
catch (Exception ex) when (ex is FormatException or OverflowException or DivideByZeroException)
{
return null;
}
}
private sealed class Parser(string text)
{
private int _position;
private int _depth;
public decimal Parse()
{
var value = Sum();
SkipSpaces();
return _position == text.Length ? value : throw new FormatException("Unerwartetes Zeichen.");
}
private decimal Sum()
{
var value = Product();
while (true)
{
SkipSpaces();
if (Match('+'))
{
value = checked(value + Product());
}
else if (Match('-') || Match('−') || Match('–'))
{
value = checked(value - Product());
}
else
{
return value;
}
}
}
private decimal Product()
{
var value = Unary();
while (true)
{
SkipSpaces();
if (Match('*') || Match('×') || Match('·'))
{
value = checked(value * Unary());
}
else if (Match('/') || Match('÷') || Match(':'))
{
var divisor = Unary();
value = divisor == 0 ? throw new DivideByZeroException() : value / divisor;
}
else
{
return value;
}
}
}
private decimal Unary()
{
SkipSpaces();
if (Match('-') || Match('−') || Match('–'))
{
return -Unary();
}
if (Match('+'))
{
return Unary();
}
return Primary();
}
private decimal Primary()
{
SkipSpaces();
if (Match('('))
{
if (++_depth > MaxDepth)
{
throw new FormatException("Zu tief verschachtelt.");
}
var inner = Sum();
SkipSpaces();
if (!Match(')'))
{
throw new FormatException("Klammer fehlt.");
}
_depth--;
return inner;
}
var start = _position;
var separators = 0;
while (_position < text.Length && (char.IsAsciiDigit(text[_position]) || text[_position] is '.' or ','))
{
if (text[_position] is '.' or ',')
{
separators++;
}
_position++;
}
if (_position == start || separators > 1)
{
throw new FormatException("Zahl erwartet.");
}
var number = text[start.._position].Replace(',', '.');
return decimal.Parse(number, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture);
}
private bool Match(char expected)
{
if (_position < text.Length && text[_position] == expected)
{
_position++;
return true;
}
return false;
}
private void SkipSpaces()
{
while (_position < text.Length && char.IsWhiteSpace(text[_position]))
{
_position++;
}
}
}
}
@@ -0,0 +1,52 @@
using FamilyPlanner.Domain.Learning;
namespace FamilyPlanner.UnitTests.Learning;
public sealed class LearningExpressionTests
{
[Theory]
[InlineData("1+2", "3")]
[InlineData("9423 / 2", "4711.5")]
[InlineData("9423:2", "4711.5")]
[InlineData("7 × 8", "56")]
[InlineData("7 · 8 ÷ 2", "28")]
[InlineData("2 + 3 * 4", "14")]
[InlineData("(2 + 3) * 4", "20")]
[InlineData("12,5 + 0,5", "13.0")]
[InlineData("1.5*2", "3.0")]
[InlineData("-3 + 5", "2")]
[InlineData("5 − 8", "-3")]
[InlineData("2 - -3", "5")]
[InlineData("((1+2)*(3+4))", "21")]
[InlineData("100 - 5 - 5", "90")]
[InlineData("100 / 4 / 5", "5")]
public void Evaluates_simple_school_arithmetic(string expression, string expected) =>
Assert.Equal(decimal.Parse(expected, System.Globalization.CultureInfo.InvariantCulture), LearningExpression.TryEvaluate(expression));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("abc")]
[InlineData("1 +")]
[InlineData("(1 + 2")]
[InlineData("1 + 2)")]
[InlineData("1,2,3 + 1")]
[InlineData("1 / 0")]
[InlineData("5 : (3 - 3)")]
[InlineData("2 ^ 3")]
[InlineData("Math.Sqrt(4)")]
[InlineData("1 2")]
[InlineData("79228162514264337593543950335 + 1")]
[InlineData("79228162514264337593543950335 * 2")]
public void Rejects_everything_else_without_throwing(string? expression) =>
Assert.Null(LearningExpression.TryEvaluate(expression));
[Fact]
public void Rejects_overlong_and_deeply_nested_input()
{
Assert.Null(LearningExpression.TryEvaluate(new string('1', LearningExpression.MaxLength + 1)));
Assert.Null(LearningExpression.TryEvaluate(new string('(', 30) + "1" + new string(')', 30)));
Assert.Equal(1m, LearningExpression.TryEvaluate(new string('(', 10) + "1" + new string(')', 10)));
}
}