"""Safe arithmetic solver: AST-based evaluation with a strict node whitelist. Supports +, -, *, /, //, %, **, parentheses, unary +/-, decimals, and a small set of worded forms ("what is 17% of 340", "sum of 4 and 9"). Never uses `eval`/`exec` — expressions are parsed with `ast.parse` and walked against an explicit allowlist of node types and operators, rejecting anything else (names, calls, attribute access, comprehensions, etc.) to stay safe against adversarial input. """ from __future__ import annotations import ast import operator import re from routing_agent.classifier import TaskType from routing_agent.models import Task from routing_agent.solvers import SolverResult _BIN_OPS: dict[type[ast.operator], object] = { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv, ast.Mod: operator.mod, ast.Pow: operator.pow, } _UNARY_OPS: dict[type[ast.unaryop], object] = { ast.UAdd: operator.pos, ast.USub: operator.neg, } _MAX_EXPONENT = 1000 # guard against pathological ** blowups _MAX_ABS_RESULT = 1e18 _PERCENT_OF_RE = re.compile( r"(?P\d+(?:\.\d+)?)\s*%\s*of\s+(?P\d+(?:\.\d+)?)", re.IGNORECASE ) _QUESTION_STRIP_RE = re.compile(r"^(what\s+is|calculate|compute|evaluate)\s*:?\s*", re.IGNORECASE) _TRAILING_STRIP_RE = re.compile(r"[?.!\s]+$") class _UnsafeExpression(Exception): """Raised when the expression contains a disallowed AST node.""" def _eval_node(node: ast.AST) -> float: if isinstance(node, ast.Expression): return _eval_node(node.body) if isinstance(node, ast.Constant): if isinstance(node.value, int | float) and not isinstance(node.value, bool): return node.value raise _UnsafeExpression(f"disallowed constant: {node.value!r}") if isinstance(node, ast.BinOp): op_fn = _BIN_OPS.get(type(node.op)) if op_fn is None: raise _UnsafeExpression(f"disallowed operator: {type(node.op).__name__}") left = _eval_node(node.left) right = _eval_node(node.right) if isinstance(node.op, ast.Pow) and abs(right) > _MAX_EXPONENT: raise _UnsafeExpression("exponent too large") result = op_fn(left, right) if abs(result) > _MAX_ABS_RESULT: raise _UnsafeExpression("result magnitude too large") return result if isinstance(node, ast.UnaryOp): op_fn = _UNARY_OPS.get(type(node.op)) if op_fn is None: raise _UnsafeExpression(f"disallowed unary operator: {type(node.op).__name__}") return op_fn(_eval_node(node.operand)) raise _UnsafeExpression(f"disallowed node: {type(node).__name__}") def _safe_eval(expression: str) -> float | None: try: tree = ast.parse(expression, mode="eval") return _eval_node(tree) except (SyntaxError, ZeroDivisionError, _UnsafeExpression, RecursionError): return None def _format_result(value: float) -> str: if isinstance(value, float) and value.is_integer(): return str(int(value)) return str(round(value, 10)).rstrip("0").rstrip(".") if isinstance(value, float) else str(value) def _extract_expression(prompt: str) -> str | None: text = _QUESTION_STRIP_RE.sub("", prompt.strip()) text = _TRAILING_STRIP_RE.sub("", text) if not text: return None # Reject anything containing letters (worded forms handled separately). if re.search(r"[a-zA-Z]", text): return None if not re.search(r"\d", text): return None return text def try_solve(task: Task, task_type: TaskType) -> SolverResult: """Attempt to solve an arithmetic task with a safe AST evaluator. Returns confident=True only for clean numeric expressions or the "X% of Y" worded form; anything ambiguous (extra prose, multiple questions, unsupported functions) yields confident=False. """ if task_type != TaskType.ARITHMETIC: return SolverResult(answer=None, confident=False) prompt = task.prompt.strip() percent_match = _PERCENT_OF_RE.fullmatch( _TRAILING_STRIP_RE.sub("", _QUESTION_STRIP_RE.sub("", prompt.strip())) ) if percent_match: pct = float(percent_match.group("pct")) base = float(percent_match.group("base")) value = (pct / 100.0) * base return SolverResult(answer=_format_result(value), confident=True) expression = _extract_expression(prompt) if expression is None: return SolverResult(answer=None, confident=False) value = _safe_eval(expression) if value is None: return SolverResult(answer=None, confident=False) return SolverResult(answer=_format_result(value), confident=True)