| |
| import re |
| import logging |
| import re |
| import logging |
| import time |
| import asyncio |
| from typing import Tuple, List, Optional, Any |
| import sympy |
| from sympy.parsing.sympy_parser import parse_expr |
| from utils.math_utils import aggressive_sympy_sanitizer |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| _PIPE_SEPARATED_RESULT = re.compile(r'\|') |
| _HEBREW_ONLY = re.compile(r'^[\u0590-\u05FF\s]+$') |
|
|
| |
| _LATEX_STRIP = re.compile( |
| r'\\(?:left|right|div|pm|mp|leq|geq|neq' |
| r'|approx|infty|text|mathrm|mathbf|boxed|underbrace|overbrace|hat|bar|vec|dot|overline|underline)\b' |
| ) |
|
|
| |
| _MATH_FUNC_PARENS = ['ln', 'sin', 'cos', 'tan', 'sqrt', 'log', 'exp', 'Abs'] |
|
|
| def _latex_to_sympy_str(latex_str: str) -> str: |
| """ |
| Best-effort LaTeX → SymPy-parseable string. |
| V310.0: Aggressive Hebrew stripping and malformed notation cleanup. |
| """ |
| if latex_str is None: |
| return "" |
| s = str(latex_str).strip() |
| |
| |
| s = re.sub(r'[\u0590-\u05FF\u200B-\u200D\uFEFF]', ' ', s) |
| |
| |
| loop_counter = 0 |
| max_loops = 15 |
| while r'\frac' in s and loop_counter < max_loops: |
| old_s = s |
| s = re.sub(r'\\frac\s*\{([^{}]*)\}\s*\{([^{}]*)\}', r'(\1)/(\2)', s) |
| if old_s == s: |
| s = s.replace(r'\frac', '(frac_err)') |
| break |
| loop_counter += 1 |
| |
| |
| s = s.replace(r'\ln', ' ln ').replace(r'\sin', ' sin ').replace(r'\cos', ' cos ') |
| s = s.replace(r'\tan', ' tan ').replace(r'\sqrt', ' sqrt ').replace(r'\log', ' log ') |
| s = s.replace(r'\exp', ' exp ').replace(r'\pi', ' pi ').replace(r'\theta', ' theta ') |
|
|
| |
| s = s.replace(r'\cdot', '*').replace(r'\times', '*') |
| |
|
|
| |
| s = _LATEX_STRIP.sub(' ', s) |
| |
| |
| s = s.replace('{', '(').replace('}', ')').replace('$', '') |
| s = s.replace(r'\left', '').replace(r'\right', '') |
| |
| |
| for func in _MATH_FUNC_PARENS: |
| pattern = r'\b' + func + r'\b\s*([^( \t\n\r\f\v,]+)' |
| s = re.sub(pattern, func + r'(\1)', s) |
|
|
| |
| loop_counter = 0 |
| while '|' in s and s.count('|') >= 2 and loop_counter < 10: |
| s = re.sub(r'\|([^|]+)\|', r'Abs(\1)', s) |
| loop_counter += 1 |
| |
| |
| s = re.sub(r'(\d)([a-zA-Z(])', r'\1*\2', s) |
| |
| |
| |
| |
| |
| s = re.sub(r'[?!\'"]', '', s) |
| s = re.sub(r'\s+', ' ', s) |
| return s.strip() |
|
|
| def _is_plaintext(expr_str: str) -> bool: |
| if _HEBREW_ONLY.match(expr_str): |
| return True |
| if _PIPE_SEPARATED_RESULT.search(expr_str) and not any(c in expr_str for c in ['+', '-', '*', '/', '^', '=']): |
| return True |
| return False |
|
|
| class MathPolygraph: |
| TIMEOUT_SECONDS = 3 |
|
|
| @staticmethod |
| async def _validate_single(text: str, step_id) -> Tuple[bool, str]: |
| """ |
| V280.0 REDESIGN: |
| 1. No Blind Stripping: Extracts $...$ or $$...$$ using re.finditer with DOTALL. |
| 2. Security: Uses parse_expr(evaluate=False). |
| 3. Equations: Splits by '=' and validates parts to bypass SymPy's '=' limitation. |
| 4. Multi-Equal: Handles x=y=5 without crashing. |
| 5. Empty Guard: Skips $$$$. |
| """ |
| if not text or not text.strip(): |
| return True, "" |
|
|
| |
| |
| math_pattern = re.compile(r'\$\$(.*?)\$\$|\$(.*?)\$', re.DOTALL) |
| matches = list(re.finditer(math_pattern, text)) |
| |
| if not matches: |
| |
| |
| if _is_plaintext(text): |
| return True, "" |
| return await MathPolygraph._check_segment(text, step_id) |
|
|
| for match in matches: |
| |
| content = (match.group(1) or match.group(2) or "").strip() |
| |
| |
| if not content: |
| continue |
| |
| |
| |
| sub_segments = [s.strip() for s in content.split('\n') if s.strip()] |
| for sub in sub_segments: |
| ok, reason = await MathPolygraph._check_segment(sub, step_id) |
| if not ok: |
| return False, reason |
| |
| return True, "" |
|
|
| @staticmethod |
| async def _run_safe_math_op(func, *args, timeout_sec: float = 0.3) -> Any: |
| """ |
| V9.0.5: Executes a CPU-bound SymPy operation in a separate thread with a strict timeout. |
| Returns the result of the function, or raises TimeoutError/Exception. |
| """ |
| try: |
| return await asyncio.wait_for( |
| asyncio.to_thread(func, *args), |
| timeout=timeout_sec |
| ) |
| except asyncio.TimeoutError: |
| raise asyncio.TimeoutError("SymPy operation timed out") |
|
|
| @staticmethod |
| async def _check_segment(raw_segment: str, step_id) -> Tuple[bool, str]: |
| """Internal helper to validate a single extracted math segment.""" |
| |
| sanitized_parts = aggressive_sympy_sanitizer(raw_segment) |
| |
| if not sanitized_parts: |
| return True, "" |
|
|
| for part in sanitized_parts: |
| sympy_str = _latex_to_sympy_str(part) |
| if not sympy_str or sympy_str in ('', '-', '()', '( )'): |
| continue |
| |
| try: |
| |
| |
| def parse_and_eval(s): |
| |
| safe_pattern = r'^[a-zA-Z0-9\s\+\-\*\/\^\(\)\.\,\!\%\=]+$' |
| if not re.match(safe_pattern, s): |
| return False |
| res = parse_expr(s, evaluate=False) |
| if res is not None: |
| evaluated = res.doit() |
| if hasattr(evaluated, 'is_finite') and evaluated.is_finite is False: |
| return False |
| if hasattr(evaluated, 'is_nan') and evaluated.is_nan: |
| return False |
| return True |
|
|
| status = await MathPolygraph._run_safe_math_op(parse_and_eval, sympy_str) |
| |
| if status is False: |
| logger.warning(f"🛡️ [SOFT FAIL] SymPy Parse Error on part '{part}'. Bypassing validator.") |
| return True, "" |
| |
| except asyncio.TimeoutError: |
| logger.warning(f"🛡️ [SOFT FAIL] SymPy TIMEOUT (300ms) on part '{part}'. Bypassing validator.") |
| return True, "" |
| except Exception as e: |
| logger.warning(f"🛡️ [SOFT FAIL] Unexpected validation crash: {e} for part '{part}'. Bypassing.") |
| return True, "" |
|
|
| return True, "" |
|
|
| @staticmethod |
| async def validate_step_sequence(steps: List[dict], topic: str = "GENERAL") -> Tuple[bool, str]: |
| if not steps: |
| return True, "" |
| |
| |
| is_sequence = topic and "SEQUENCE" in topic.upper() |
| |
| for step in steps: |
| step_id = step.get('step_id', step.get('step_number', '?')) |
| math_fields = [] |
| for field in ('math_latex', 'block_math', 'math'): |
| val = step.get(field) |
| if val and isinstance(val, str) and val.strip(): |
| math_fields.append(val.strip()) |
| if not math_fields: |
| continue |
| |
| |
| if is_sequence: |
| |
| if math_fields[0].count('{') != math_fields[0].count('}'): |
| return False, f"LATEX_BRACKET_MISMATCH:step_{step_id}" |
| continue |
|
|
| ok, reason = await MathPolygraph._validate_single(math_fields[0], step_id) |
| if not ok: |
| return False, reason |
| return True, "" |
| @staticmethod |
| async def are_equivalent(latex1: str, latex2: str) -> bool: |
| """ |
| V9.0.5: Checks if two LaTeX expressions are mathematically equivalent (Non-Blocking). |
| Supports expressions and equations (by converting to 'expr = 0'). |
| """ |
| try: |
| |
| if '=' in latex1 and '=' in latex2 and latex1.count('=') == 1 and latex2.count('=') == 1: |
| parts1 = [p.strip() for p in latex1.split('=') if p.strip()] |
| parts2 = [p.strip() for p in latex2.split('=') if p.strip()] |
| if len(parts1) == 2 and len(parts2) == 2: |
| res1 = await MathPolygraph.are_equivalent(parts1[0], parts2[0]) |
| res2 = await MathPolygraph.are_equivalent(parts1[1], parts2[1]) |
| return res1 and res2 |
|
|
| s1_raw = _latex_to_sympy_str(latex1) |
| s2_raw = _latex_to_sympy_str(latex2) |
| |
| |
| inequalities = ['<', '>', r'\leq', r'\geq', r'\neq', r'\leq', r'\geq'] |
| if any(iq in latex1 for iq in inequalities) or any(iq in latex2 for iq in inequalities): |
| return latex1.strip() == latex2.strip() |
|
|
| |
| safe_pattern = r'^[a-zA-Z0-9\s\+\-\*\/\^\(\)\.\,\!\=]+$' |
| def is_safe(s): |
| clean = s.replace('\\', '').replace('_', '').replace('{', '(').replace('}', ')') |
| return bool(re.match(safe_pattern, clean)) |
| |
| if not (is_safe(s1_raw) and is_safe(s2_raw)): |
| return latex1.strip() == latex2.strip() |
|
|
| |
| def calc_equivalence(str1, str2): |
| expr1 = parse_expr(str1, evaluate=False) |
| expr2 = parse_expr(str2, evaluate=False) |
| |
| if len(expr1.free_symbols) > 0 or len(expr2.free_symbols) > 0: |
| return sympy.simplify(expr1 - expr2) == 0 |
| |
| diff = sympy.simplify(expr1 - expr2) |
| return diff == 0 |
|
|
| return await MathPolygraph._run_safe_math_op(calc_equivalence, s1_raw, s2_raw) |
|
|
| except asyncio.TimeoutError: |
| logger.warning(f"🛡️ [SOFT FAIL] Equivalence check TIMEOUT (300ms) for {latex1} vs {latex2}") |
| return True |
| except Exception as e: |
| logger.warning(f"🛡️ [SOFT FAIL] Equivalence check failed: {e}") |
| return True |
|
|
| @staticmethod |
| async def verify_algebraic_consistency(steps: List[dict], topic: str = "GENERAL") -> Tuple[bool, str]: |
| """ |
| V1.3: Checks if a sequence of steps is algebraically consistent. |
| Currently checks if subsequent steps are equivalent (for simplifications). |
| """ |
| |
| if topic and "SEQUENCE" in topic.upper(): |
| return True, "" |
| |
| math_steps = [] |
| for step in steps: |
| math = step.get('math_latex') or step.get('block_math') or step.get('math') |
| if math and isinstance(math, str) and math.strip(): |
| |
| if not _is_plaintext(math): |
| math_steps.append({'id': step.get('step_id', '?'), 'math': math}) |
| |
| if len(math_steps) < 2: |
| return True, "" |
| |
| for i in range(len(math_steps) - 1): |
| s1 = math_steps[i]['math'] |
| s2 = math_steps[i+1]['math'] |
| |
| |
| if not await MathPolygraph.are_equivalent(s1, s2): |
| logger.info(f"[POLYGRAPH] Consistency warning between {s1} and {s2}") |
| |
| |
| return False, f"ALGEBRAIC_INCONSISTENCY:step_{math_steps[i+1]['id']}" |
| |
| return True, "" |
|
|