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:
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)));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user