Spaces:
Paused
Paused
File size: 7,938 Bytes
917e554 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 | """
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 |