| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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...") |
|
|
| |
| 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}") |
|
|
| |
| 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) |
|
|
| |
| for raw_point_str in data_anchor.get("points", []): |
| try: |
| |
| 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}") |
|
|
| |
| if circle: |
| for name, pt in parsed_points.items(): |
| check_point_on_circle(pt, circle, anchor) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|