# geometric_sanity.py - V1.0 (Geometric Sanity Engine) # This module is the public entry point for the sanity check layer. # # Responsibilities: # 1. Parse the raw DataAnchor dict into typed geometric objects. # 2. Run all relevant constraint checks (via constraint_checks.py). # 3. Build the "VERIFIED FACTS" prompt block for injection into the LLM prompt. # # Design note: All errors are caught and logged. This module NEVER raises — # it always returns a valid (possibly empty) GeometryAnchor and prompt block. import logging from sympy import Rational, sympify from geometry_types import Point, Line, Segment, Circle, GeometryAnchor from constraint_checks import ( check_point_on_circle, check_point_on_line, check_is_diameter, check_distance, find_circle_center_from_diameters, ) logger = logging.getLogger(__name__) def _parse_point(raw: str, name: str) -> Point | None: """ Parses a string like '(3, 5)' or '3,5' into a Point with SymPy Rational coords. Returns None if unparseable (so the engine degrades gracefully). """ try: clean = raw.strip().strip("()") parts = clean.split(",") if len(parts) != 2: return None x = Rational(parts[0].strip()) y = Rational(parts[1].strip()) return Point(name=name, x=x, y=y) except Exception as e: logger.debug(f"[GEO-SANITY] Could not parse point '{raw}' as '{name}': {e}") return None def _parse_radius(raw) -> object | None: """ Parses a radius value into a SymPy Rational or expression. """ try: return Rational(raw) except Exception: try: return sympify(str(raw)) except Exception as e: logger.debug(f"[GEO-SANITY] Could not parse radius '{raw}': {e}") return None def _build_prompt_block(anchor: GeometryAnchor) -> str: """ Builds the 'VERIFIED MATHEMATICAL FACTS' block to be injected before LLM generation. This is the final output consumed by prompts.py / strategy_manager.py. """ facts_text = "\n".join(anchor.verified_facts) if anchor.verified_facts else "אין עובדות מאומתות." warnings_text = "\n".join(anchor.warnings) if anchor.warnings else "אין סתירות." block = f""" ╔══════════════════════════════════════════════════════╗ ║ VERIFIED MATHEMATICAL FACTS (GROUND TRUTH — V1.0) ║ ╚══════════════════════════════════════════════════════╝ {facts_text} ╔══════════════════════════════════════════════════════╗ ║ CONSTRAINT VIOLATIONS (READ CAREFULLY) ║ ╚══════════════════════════════════════════════════════╝ {warnings_text} ⚠️ הוראה קריטית: אם האינטואיציה שלך סותרת עובדה מאומתת לעיל, **העובדה האלגברית תמיד מנצחת**. אל תמציא נתונים גיאומטריים שאינם מאושרים כ-Verified Fact. כל ה-Verified Facts חושבו ע"י SymPy ואינם ניתנים לערעור. ╔══════════════════════════════════════════════════════╝ """ return block def run_geometric_sanity(data_anchor: dict) -> tuple[GeometryAnchor, str]: """ V1.0: Main entry point. Parses the data_anchor dict and runs all applicable checks. Args: data_anchor: dict — the raw anchor extracted by orchestrator._extract_key_data() Returns: (GeometryAnchor, str) — the populated anchor + the prompt block for LLM injection. This function NEVER raises. All exceptions are caught and logged. """ anchor = GeometryAnchor() if not data_anchor: logger.info("[GEO-SANITY] Empty data_anchor — skipping geometric checks.") return anchor, "" logger.info("[GEO-SANITY] 🔍 Starting Geometric Sanity Check...") # ─── 1. Parse raw center & radius ─────────────────────────────────────────── raw_center = data_anchor.get("center") raw_radius = data_anchor.get("radius") circle = None if raw_center and len(raw_center) == 2: try: cx = Rational(raw_center[0]) cy = Rational(raw_center[1]) center_point = Point(name="O", x=cx, y=cy) except Exception as e: center_point = None logger.warning(f"[GEO-SANITY] Could not parse center {raw_center}: {e}") else: center_point = None if center_point and raw_radius is not None: r = _parse_radius(raw_radius) if r is not None: circle = Circle(center=center_point, radius=r) anchor.circles.append(circle) anchor.add_fact(f"✓ מעגל נטען: מרכז {center_point}, r={r}") # ─── 2. Parse named points ──────────────────────────────────────────────── parsed_points: dict[str, Point] = {} for field_name in ["point_a", "point_b", "point_c", "point_d"]: raw = data_anchor.get(field_name) if raw: label = field_name.split("_")[-1].upper() pt = _parse_point(str(raw), label) if pt: parsed_points[label] = pt anchor.points.append(pt) # Also parse points from the 'points' list (e.g., ["A(3,5)", "B(0,0)"]) for raw_point_str in data_anchor.get("points", []): try: # Match "A(3,5)" or "A(3, 5)" import re m = re.match(r'([A-Za-z]+)\s*\(([^)]+)\)', str(raw_point_str)) if m: lbl = m.group(1) coords = m.group(2).split(",") if len(coords) == 2: px = Rational(coords[0].strip()) py = Rational(coords[1].strip()) pt = Point(name=lbl, x=px, y=py) parsed_points[lbl] = pt anchor.points.append(pt) except Exception as e: logger.debug(f"[GEO-SANITY] Could not parse points entry '{raw_point_str}': {e}") # ─── 3. Run point-on-circle checks ─────────────────────────────────────── if circle: for name, pt in parsed_points.items(): check_point_on_circle(pt, circle, anchor) # ─── 4. Diameter check: if exactly 2 points, check if they form a diameter ─ if circle and len(parsed_points) >= 2: names = list(parsed_points.keys()) for i in range(len(names)): for j in range(i + 1, len(names)): p_i = parsed_points[names[i]] p_j = parsed_points[names[j]] seg = Segment(p1=p_i, p2=p_j) check_is_diameter(seg, circle, anchor) anchor.segments.append(seg) # ─── 5. Final summary log ───────────────────────────────────────────────── logger.info( f"[GEO-SANITY] ✅ Check complete. " f"Facts: {len(anchor.verified_facts)}, Warnings: {len(anchor.warnings)}" ) prompt_block = _build_prompt_block(anchor) return anchor, prompt_block def build_verified_facts_prompt(anchor: GeometryAnchor) -> str: """ Public helper: Re-builds the prompt block from an already-populated anchor. Useful for re-injection after additional checks. """ return _build_prompt_block(anchor)