MathMate / guardrails.py
MIHIR MITHANI
Deploy Streamlit app
0d1528e
Raw History Blame Contribute Delete
7.94 kB
"""
Guardrails: input gate, leak check, refusal and fallback templates.
"""
import re
from typing import Optional, Tuple, List, Dict, Any
# --- Input Gate (code checks) ---
INJECTION_PATTERNS = [
r"ignore\s+previous\s+instructions",
r"ignore\s+all\s+instructions",
r"system\s+prompt",
r"reveal\s+the\s+solution",
r"reveal\s+hidden",
r"just\s+give\s+me\s+the\s+answer",
r"act\s+as\s+",
]
NUMBER_WORDS = {
"zero": 0, "one": 1, "two": 2, "three": 3, "four": 4, "five": 5,
"six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10,
"eleven": 11, "twelve": 12, "thirteen": 13, "fourteen": 14, "fifteen": 15,
"sixteen": 16, "seventeen": 17, "eighteen": 18, "nineteen": 19, "twenty": 20,
"thirty": 30, "forty": 40, "fifty": 50, "sixty": 60, "seventy": 70,
"eighty": 80, "ninety": 90, "hundred": 100, "thousand": 1000,
"million": 1000000, "billion": 1000000000,
}
# Refusal messages (to be used verbatim)
REFUSALS = {
"empty": "I can't help with an empty problem. Please provide a math word problem.",
"off_topic": "This doesn't look like a math word problem. Please provide a problem with numbers and a question.",
"prompt_injection": "I can't process that request. Please provide a genuine math word problem.",
}
# Safe fallback hints
SAFE_FALLBACK = {
1: "What information is given in the problem? What are you being asked to find?",
2: "What mathematical concept or formula might apply here? What's the first step?",
3: "Let me walk through the solution step by step.",
}
def input_gate_code(text: str) -> Optional[str]:
"""
Code-based input gate checks.
Returns rejection category ("empty", "off_topic", "prompt_injection") or None if allowed.
"""
if not text or not text.strip():
return "empty"
text_stripped = text.strip()
# Length checks
if len(text_stripped) < 15:
return "off_topic"
if len(text_stripped) > 1500:
return "off_topic"
# Prompt injection check
text_lower = text_stripped.lower()
for pattern in INJECTION_PATTERNS:
if re.search(pattern, text_lower, re.IGNORECASE):
return "prompt_injection"
# Check for at least one digit or number word
has_digit = bool(re.search(r"\d", text_stripped))
has_number_word = any(word in text_lower for word in NUMBER_WORDS.keys())
if not has_digit and not has_number_word:
return "off_topic"
return None
# --- Leak Check (code checks) ---
PHRASE_BLOCKLIST = [
"the answer is",
"answer:",
"so the result is",
"final answer",
]
def _normalize_number_text(text: str) -> str:
"""Normalize numeric text: strip units, commas, currency symbols."""
# Remove currency symbols
text = re.sub(r"[\$\£\€\¥]", "", text)
# Remove commas
text = text.replace(",", "")
# Remove units (anything after a number that's letters)
text = re.sub(r"(\d+(?:\.\d+)?)\s*[a-zA-Z]+", r"\1", text)
return text.strip()
def _extract_numbers_from_text(text: str) -> List[str]:
"""Extract all numbers (integers, decimals, fractions) as normalized strings."""
numbers = []
# Pattern for fractions (e.g., 3/4, 15/8)
for match in re.finditer(r"\b(\d+)\s*/\s*(\d+)\b", text):
num = int(match.group(1))
den = int(match.group(2))
if den != 0:
numbers.append(f"{num}/{den}")
# Also add decimal equivalent
numbers.append(f"{num/den:.10f}".rstrip('0').rstrip('.'))
# Pattern for decimals and integers
for match in re.finditer(r"\b(\d+(?:\.\d+)?)\b", text):
val = match.group(1)
# Normalize: strip leading zeros but keep at least one digit
val_norm = val.lstrip('0')
if val_norm == '' or val_norm.startswith('.'):
val_norm = '0' + val_norm
numbers.append(val_norm)
return numbers
def _number_to_words(n: float) -> List[str]:
"""Convert a number to possible word forms."""
words = []
# Integer form
if n == int(n):
words.append(str(int(n)))
# Decimal form
words.append(f"{n:.10f}".rstrip('0').rstrip('.'))
# Word form for small integers
if n == int(n) and 0 <= int(n) <= 20:
for word, val in NUMBER_WORDS.items():
if val == int(n):
words.append(word)
return words
def _get_answer_variants(answer: str) -> List[str]:
"""Build all variants of the answer for matching."""
variants = set()
normalized = _normalize_number_text(answer)
variants.add(normalized)
# Try to parse as number
try:
# Handle fractions
if '/' in normalized:
parts = normalized.split('/')
num = float(parts[0])
den = float(parts[1])
if den != 0:
val = num / den
variants.update(_number_to_words(val))
# Add fraction variants
variants.add(f"{int(num)}/{int(den)}")
else:
val = float(normalized)
variants.update(_number_to_words(val))
except (ValueError, ZeroDivisionError):
pass
return list(variants)
def _get_last_step_result(solution: str) -> Optional[str]:
"""Extract the last step's result from a solution."""
lines = solution.strip().split('\n')
for line in reversed(lines):
line = line.strip()
if '=' in line:
# Get RHS of last equation
rhs = line.split('=')[-1].strip()
return _normalize_number_text(rhs)
return None
def leak_check_code(hint_text: str, solution: str, problem_text: str) -> Tuple[bool, str]:
"""
Check if hint leaks the answer.
Returns (is_leak, reason).
"""
hint_lower = hint_text.lower()
# Phrase blocklist check (always applies)
for phrase in PHRASE_BLOCKLIST:
if phrase.lower() in hint_lower:
return True, f"Forbidden phrase detected: '{phrase}'"
# Get answer variants
answer_variants = _get_answer_variants(solution)
# Get problem numbers (to allow numbers that appear in the problem)
problem_numbers = set(_extract_numbers_from_text(problem_text))
problem_normalized = _normalize_number_text(problem_text)
# Check if answer number appears as standalone token in hint
hint_numbers = set(_extract_numbers_from_text(hint_text))
for variant in answer_variants:
if variant in hint_numbers:
# Check if this number appears in the problem text
if variant not in problem_numbers:
return True, f"Answer number '{variant}' appears in hint but not in problem"
# Level 2 check: last step's result
last_step_result = _get_last_step_result(solution)
if last_step_result:
last_step_variants = _get_answer_variants(last_step_result)
for variant in last_step_variants:
if variant in hint_numbers:
if variant not in problem_numbers:
return True, f"Last step result '{variant}' appears in hint but not in problem"
return False, "No leak detected"
def normalise_numeric_equal(a: str, b: str) -> bool:
"""
Compare two solver outputs numerically.
Normalizes both and checks if they represent the same value within tolerance.
"""
try:
a_norm = _normalize_number_text(a)
b_norm = _normalize_number_text(b)
# Try parsing as fractions first
def parse_val(s: str) -> Optional[float]:
if '/' in s:
parts = s.split('/')
return float(parts[0]) / float(parts[1])
return float(s)
a_val = parse_val(a_norm)
b_val = parse_val(b_norm)
if a_val is not None and b_val is not None:
return abs(a_val - b_val) < 1e-6
# Fallback to string comparison
return a_norm == b_norm
except (ValueError, ZeroDivisionError):
return a == b