BuddyMath / geometric_sanity.py
dotandru's picture
feat: implement Geometric Sanity Engine (V1.0) and fix project ID in functions
4de02b8
Raw
History Blame Contribute Delete
8.14 kB
# 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)