feat: implement Geometric Sanity Engine (V1.0) and fix project ID in functions
Browse files- .gcloudignore +16 -0
- constraint_checks.py +159 -0
- geometric_sanity.py +191 -0
- geometry_types.py +122 -0
- orchestrator.py +15 -0
- prompts.py +10 -0
.gcloudignore
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.gcloudignore
|
| 2 |
+
.git
|
| 3 |
+
.gitignore
|
| 4 |
+
venv/
|
| 5 |
+
.venv/
|
| 6 |
+
.venv_fix/
|
| 7 |
+
__pycache__/
|
| 8 |
+
*.pyc
|
| 9 |
+
*.pyo
|
| 10 |
+
*.pyd
|
| 11 |
+
.pytest_cache/
|
| 12 |
+
tests/
|
| 13 |
+
test_results/
|
| 14 |
+
logs/
|
| 15 |
+
server.log
|
| 16 |
+
deploy_hf/
|
constraint_checks.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# constraint_checks.py - V1.0 (Geometric Constraint Validator)
|
| 2 |
+
# Deterministic algebraic checks using SymPy.
|
| 3 |
+
# Each function verifies a specific geometric relationship and logs
|
| 4 |
+
# the result to the GeometryAnchor (verified_facts or warnings).
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
from sympy import simplify, symbols, Eq, solve, Rational
|
| 8 |
+
|
| 9 |
+
from geometry_types import Point, Line, Segment, Circle, GeometryAnchor
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def check_point_on_circle(p: Point, circle: Circle, anchor: GeometryAnchor):
|
| 15 |
+
"""
|
| 16 |
+
Verifies algebraically whether point p lies on the circle boundary.
|
| 17 |
+
Injects a verified_fact or a warning into the anchor.
|
| 18 |
+
"""
|
| 19 |
+
try:
|
| 20 |
+
if circle.contains(p):
|
| 21 |
+
anchor.add_fact(
|
| 22 |
+
f"โ ืื ืงืืื {p} ื ืืฆืืช ืขื ืืืขืื (ืืจืื {circle.center}, r={circle.radius})"
|
| 23 |
+
)
|
| 24 |
+
else:
|
| 25 |
+
lhs = (p.x - circle.center.x)**2 + (p.y - circle.center.y)**2
|
| 26 |
+
anchor.add_warning(
|
| 27 |
+
f"โ ืื ืงืืื {p} ืืื ื ืขื ืืืขืื โ "
|
| 28 |
+
f"ืืืฉืื: {simplify(lhs)} โ rยฒ={simplify(circle.radius**2)}. "
|
| 29 |
+
f"ืืืชืื ืฉื-OCR ืืืืฅ ืงืืืืจืืื ืืืช ืฉืืืืืช."
|
| 30 |
+
)
|
| 31 |
+
except Exception as e:
|
| 32 |
+
logger.error(f"[CONSTRAINT] check_point_on_circle failed: {e}")
|
| 33 |
+
anchor.add_warning(f"โ ๏ธ ืื ื ืืชื ืืืืช ืฉืื ืงืืื {p.name} ืขื ืืืขืื: {e}")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def check_point_on_line(p: Point, line: Line, anchor: GeometryAnchor):
|
| 37 |
+
"""
|
| 38 |
+
Verifies algebraically whether point p lies on the line ax + by + c = 0.
|
| 39 |
+
"""
|
| 40 |
+
try:
|
| 41 |
+
if line.contains(p):
|
| 42 |
+
anchor.add_fact(
|
| 43 |
+
f"โ ืื ืงืืื {p} ื ืืฆืืช ืขื ืืืฉืจ {line}"
|
| 44 |
+
)
|
| 45 |
+
else:
|
| 46 |
+
val = simplify(line.a * p.x + line.b * p.y + line.c)
|
| 47 |
+
anchor.add_warning(
|
| 48 |
+
f"โ ืื ืงืืื {p} ืืื ื ืขื ืืืฉืจ {line} โ "
|
| 49 |
+
f"ืืฆืื ื ืืชื ืช {val} โ 0"
|
| 50 |
+
)
|
| 51 |
+
except Exception as e:
|
| 52 |
+
logger.error(f"[CONSTRAINT] check_point_on_line failed: {e}")
|
| 53 |
+
anchor.add_warning(f"โ ๏ธ ืื ื ืืชื ืืืืช ืฉืื ืงืืื {p.name} ืขื ืืืฉืจ: {e}")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def check_is_diameter(segment: Segment, circle: Circle, anchor: GeometryAnchor):
|
| 57 |
+
"""
|
| 58 |
+
Checks if a segment is a diameter by verifying its midpoint equals the circle center.
|
| 59 |
+
This is the most critical check for preventing midpoint hallucination.
|
| 60 |
+
"""
|
| 61 |
+
try:
|
| 62 |
+
mid = segment.midpoint()
|
| 63 |
+
center = circle.center
|
| 64 |
+
if mid == center:
|
| 65 |
+
anchor.add_fact(
|
| 66 |
+
f"โ ืืงืืข {segment.p1.name}{segment.p2.name} ืืื ืงืืืจ โ "
|
| 67 |
+
f"ืืืฆืขื {mid} = ืืจืื ืืืขืื {center}"
|
| 68 |
+
)
|
| 69 |
+
else:
|
| 70 |
+
anchor.add_warning(
|
| 71 |
+
f"โ ืืงืืข {segment.p1.name}{segment.p2.name} ืืื ื ืงืืืจ โ "
|
| 72 |
+
f"ืืืฆืข ืืงืืข ืืื {mid}, ืื ืืจืื ืืืขืื ืืื {center}. "
|
| 73 |
+
f"โ ๏ธ ืื ืชื ืื ืฉืืจืื ืืืขืื ืืื ื ืงืืืช ืืืืฆืข ืฉื ืงืืข ืื!"
|
| 74 |
+
)
|
| 75 |
+
except Exception as e:
|
| 76 |
+
logger.error(f"[CONSTRAINT] check_is_diameter failed: {e}")
|
| 77 |
+
anchor.add_warning(f"โ ๏ธ ืื ื ืืชื ืืืืช ืืื ืืงืืข {segment} ืืื ืงืืืจ: {e}")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def check_perpendicular_lines(slope1, slope2, anchor: GeometryAnchor, label1="ืืฉืจ 1", label2="ืืฉืจ 2"):
|
| 81 |
+
"""
|
| 82 |
+
Verifies that two lines (given by slopes) are perpendicular: m1 * m2 = -1.
|
| 83 |
+
"""
|
| 84 |
+
try:
|
| 85 |
+
product = simplify(slope1 * slope2)
|
| 86 |
+
if product == -1:
|
| 87 |
+
anchor.add_fact(
|
| 88 |
+
f"โ {label1} ื-{label2} ืืืื ืืื โ ืืืคืืช ืฉืืคืืขืื = {product} = -1"
|
| 89 |
+
)
|
| 90 |
+
else:
|
| 91 |
+
anchor.add_warning(
|
| 92 |
+
f"โ {label1} ื-{label2} ืืื ื ืืืื ืืื โ ืืืคืืช ืฉืืคืืขืื = {product} โ -1"
|
| 93 |
+
)
|
| 94 |
+
except Exception as e:
|
| 95 |
+
logger.error(f"[CONSTRAINT] check_perpendicular_lines failed: {e}")
|
| 96 |
+
anchor.add_warning(f"โ ๏ธ ืื ื ืืชื ืืืืช ืื ืืืืช: {e}")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def find_circle_center_from_diameters(
|
| 100 |
+
d1: Segment,
|
| 101 |
+
d2: Segment,
|
| 102 |
+
anchor: GeometryAnchor
|
| 103 |
+
) -> Point:
|
| 104 |
+
"""
|
| 105 |
+
Finds the circle center deterministically as the intersection of two diameter segments.
|
| 106 |
+
This eliminates any need for the LLM to 'guess' the center.
|
| 107 |
+
"""
|
| 108 |
+
try:
|
| 109 |
+
x, y = symbols('x y')
|
| 110 |
+
|
| 111 |
+
def line_eq_through(p1: Point, p2: Point):
|
| 112 |
+
if simplify(p2.x - p1.x) == 0:
|
| 113 |
+
# Vertical line
|
| 114 |
+
return Eq(x, p1.x)
|
| 115 |
+
slope = Rational(p2.y - p1.y, p2.x - p1.x)
|
| 116 |
+
return Eq(y, slope * (x - p1.x) + p1.y)
|
| 117 |
+
|
| 118 |
+
eq1 = line_eq_through(d1.p1, d1.p2)
|
| 119 |
+
eq2 = line_eq_through(d2.p1, d2.p2)
|
| 120 |
+
|
| 121 |
+
solution = solve([eq1, eq2], [x, y])
|
| 122 |
+
|
| 123 |
+
if not solution:
|
| 124 |
+
anchor.add_warning(
|
| 125 |
+
"[SOLVER] ืื ื ืืชื ืืืฉื ืืจืื ืืขืื ืืืืชืื ืฉื ื ืงืืจืื โ ืืืฉืจืื ืืงืืืืื?"
|
| 126 |
+
)
|
| 127 |
+
return None
|
| 128 |
+
|
| 129 |
+
cx = solution[x] if isinstance(solution, dict) else solution[0]
|
| 130 |
+
cy = solution[y] if isinstance(solution, dict) else solution[1]
|
| 131 |
+
center = Point(name="M", x=simplify(cx), y=simplify(cy))
|
| 132 |
+
anchor.add_fact(
|
| 133 |
+
f"โ ืืจืื ืืืขืื M ืืืฉื ืืืืชืื ืงืืจืื: M = {center}"
|
| 134 |
+
)
|
| 135 |
+
return center
|
| 136 |
+
|
| 137 |
+
except Exception as e:
|
| 138 |
+
logger.error(f"[CONSTRAINT] find_circle_center_from_diameters failed: {e}")
|
| 139 |
+
anchor.add_warning(f"โ ๏ธ ืืืฉืื ืืจืื ืืืขืื ื ืืฉื: {e}")
|
| 140 |
+
return None
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def check_distance(p1: Point, p2: Point, expected_distance, anchor: GeometryAnchor, label=""):
|
| 144 |
+
"""
|
| 145 |
+
Verifies the distance between two points matches an expected value.
|
| 146 |
+
"""
|
| 147 |
+
try:
|
| 148 |
+
actual = simplify(p1.distance_to(p2))
|
| 149 |
+
expected = simplify(expected_distance)
|
| 150 |
+
desc = label or f"{p1.name}โ{p2.name}"
|
| 151 |
+
if simplify(actual - expected) == 0:
|
| 152 |
+
anchor.add_fact(f"โ ืืืจืืง {desc} = {actual} (ืชืืื ืื ืชืื)")
|
| 153 |
+
else:
|
| 154 |
+
anchor.add_warning(
|
| 155 |
+
f"โ ืืืจืืง {desc}: ืืืฉืื={actual} โ ื ืชืื={expected}"
|
| 156 |
+
)
|
| 157 |
+
except Exception as e:
|
| 158 |
+
logger.error(f"[CONSTRAINT] check_distance failed: {e}")
|
| 159 |
+
anchor.add_warning(f"โ ๏ธ ืืืืงืช ืืจืืง ื ืืฉืื: {e}")
|
geometric_sanity.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# geometric_sanity.py - V1.0 (Geometric Sanity Engine)
|
| 2 |
+
# This module is the public entry point for the sanity check layer.
|
| 3 |
+
#
|
| 4 |
+
# Responsibilities:
|
| 5 |
+
# 1. Parse the raw DataAnchor dict into typed geometric objects.
|
| 6 |
+
# 2. Run all relevant constraint checks (via constraint_checks.py).
|
| 7 |
+
# 3. Build the "VERIFIED FACTS" prompt block for injection into the LLM prompt.
|
| 8 |
+
#
|
| 9 |
+
# Design note: All errors are caught and logged. This module NEVER raises โ
|
| 10 |
+
# it always returns a valid (possibly empty) GeometryAnchor and prompt block.
|
| 11 |
+
|
| 12 |
+
import logging
|
| 13 |
+
from sympy import Rational, sympify
|
| 14 |
+
|
| 15 |
+
from geometry_types import Point, Line, Segment, Circle, GeometryAnchor
|
| 16 |
+
from constraint_checks import (
|
| 17 |
+
check_point_on_circle,
|
| 18 |
+
check_point_on_line,
|
| 19 |
+
check_is_diameter,
|
| 20 |
+
check_distance,
|
| 21 |
+
find_circle_center_from_diameters,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _parse_point(raw: str, name: str) -> Point | None:
|
| 28 |
+
"""
|
| 29 |
+
Parses a string like '(3, 5)' or '3,5' into a Point with SymPy Rational coords.
|
| 30 |
+
Returns None if unparseable (so the engine degrades gracefully).
|
| 31 |
+
"""
|
| 32 |
+
try:
|
| 33 |
+
clean = raw.strip().strip("()")
|
| 34 |
+
parts = clean.split(",")
|
| 35 |
+
if len(parts) != 2:
|
| 36 |
+
return None
|
| 37 |
+
x = Rational(parts[0].strip())
|
| 38 |
+
y = Rational(parts[1].strip())
|
| 39 |
+
return Point(name=name, x=x, y=y)
|
| 40 |
+
except Exception as e:
|
| 41 |
+
logger.debug(f"[GEO-SANITY] Could not parse point '{raw}' as '{name}': {e}")
|
| 42 |
+
return None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _parse_radius(raw) -> object | None:
|
| 46 |
+
"""
|
| 47 |
+
Parses a radius value into a SymPy Rational or expression.
|
| 48 |
+
"""
|
| 49 |
+
try:
|
| 50 |
+
return Rational(raw)
|
| 51 |
+
except Exception:
|
| 52 |
+
try:
|
| 53 |
+
return sympify(str(raw))
|
| 54 |
+
except Exception as e:
|
| 55 |
+
logger.debug(f"[GEO-SANITY] Could not parse radius '{raw}': {e}")
|
| 56 |
+
return None
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _build_prompt_block(anchor: GeometryAnchor) -> str:
|
| 60 |
+
"""
|
| 61 |
+
Builds the 'VERIFIED MATHEMATICAL FACTS' block to be injected before LLM generation.
|
| 62 |
+
This is the final output consumed by prompts.py / strategy_manager.py.
|
| 63 |
+
"""
|
| 64 |
+
facts_text = "\n".join(anchor.verified_facts) if anchor.verified_facts else "ืืื ืขืืืืืช ืืืืืชืืช."
|
| 65 |
+
warnings_text = "\n".join(anchor.warnings) if anchor.warnings else "ืืื ืกืชืืจืืช."
|
| 66 |
+
|
| 67 |
+
block = f"""
|
| 68 |
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 69 |
+
โ VERIFIED MATHEMATICAL FACTS (GROUND TRUTH โ V1.0) โ
|
| 70 |
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 71 |
+
{facts_text}
|
| 72 |
+
|
| 73 |
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 74 |
+
โ CONSTRAINT VIOLATIONS (READ CAREFULLY) โ
|
| 75 |
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 76 |
+
{warnings_text}
|
| 77 |
+
|
| 78 |
+
โ ๏ธ ืืืจืื ืงืจืืืืช: ืื ืืืื ืืืืืฆืื ืฉืื ืกืืชืจืช ืขืืืื ืืืืืชืช ืืขืื,
|
| 79 |
+
**ืืขืืืื ืืืืืืจืืช ืชืืื ืื ืฆืืช**. ืื ืชืืฆืื ื ืชืื ืื ืืืืืืืจืืื
|
| 80 |
+
ืฉืืื ื ืืืืฉืจืื ื-Verified Fact. ืื ื-Verified Facts ืืืฉืื ืข"ื
|
| 81 |
+
SymPy ืืืื ื ื ืืชื ืื ืืขืจืขืืจ.
|
| 82 |
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 83 |
+
"""
|
| 84 |
+
return block
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def run_geometric_sanity(data_anchor: dict) -> tuple[GeometryAnchor, str]:
|
| 88 |
+
"""
|
| 89 |
+
V1.0: Main entry point. Parses the data_anchor dict and runs all applicable checks.
|
| 90 |
+
|
| 91 |
+
Args:
|
| 92 |
+
data_anchor: dict โ the raw anchor extracted by orchestrator._extract_key_data()
|
| 93 |
+
|
| 94 |
+
Returns:
|
| 95 |
+
(GeometryAnchor, str) โ the populated anchor + the prompt block for LLM injection.
|
| 96 |
+
|
| 97 |
+
This function NEVER raises. All exceptions are caught and logged.
|
| 98 |
+
"""
|
| 99 |
+
anchor = GeometryAnchor()
|
| 100 |
+
|
| 101 |
+
if not data_anchor:
|
| 102 |
+
logger.info("[GEO-SANITY] Empty data_anchor โ skipping geometric checks.")
|
| 103 |
+
return anchor, ""
|
| 104 |
+
|
| 105 |
+
logger.info("[GEO-SANITY] ๐ Starting Geometric Sanity Check...")
|
| 106 |
+
|
| 107 |
+
# โโโ 1. Parse raw center & radius โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 108 |
+
raw_center = data_anchor.get("center")
|
| 109 |
+
raw_radius = data_anchor.get("radius")
|
| 110 |
+
circle = None
|
| 111 |
+
|
| 112 |
+
if raw_center and len(raw_center) == 2:
|
| 113 |
+
try:
|
| 114 |
+
cx = Rational(raw_center[0])
|
| 115 |
+
cy = Rational(raw_center[1])
|
| 116 |
+
center_point = Point(name="O", x=cx, y=cy)
|
| 117 |
+
except Exception as e:
|
| 118 |
+
center_point = None
|
| 119 |
+
logger.warning(f"[GEO-SANITY] Could not parse center {raw_center}: {e}")
|
| 120 |
+
else:
|
| 121 |
+
center_point = None
|
| 122 |
+
|
| 123 |
+
if center_point and raw_radius is not None:
|
| 124 |
+
r = _parse_radius(raw_radius)
|
| 125 |
+
if r is not None:
|
| 126 |
+
circle = Circle(center=center_point, radius=r)
|
| 127 |
+
anchor.circles.append(circle)
|
| 128 |
+
anchor.add_fact(f"โ ืืขืื ื ืืขื: ืืจืื {center_point}, r={r}")
|
| 129 |
+
|
| 130 |
+
# โโโ 2. Parse named points โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 131 |
+
parsed_points: dict[str, Point] = {}
|
| 132 |
+
|
| 133 |
+
for field_name in ["point_a", "point_b", "point_c", "point_d"]:
|
| 134 |
+
raw = data_anchor.get(field_name)
|
| 135 |
+
if raw:
|
| 136 |
+
label = field_name.split("_")[-1].upper()
|
| 137 |
+
pt = _parse_point(str(raw), label)
|
| 138 |
+
if pt:
|
| 139 |
+
parsed_points[label] = pt
|
| 140 |
+
anchor.points.append(pt)
|
| 141 |
+
|
| 142 |
+
# Also parse points from the 'points' list (e.g., ["A(3,5)", "B(0,0)"])
|
| 143 |
+
for raw_point_str in data_anchor.get("points", []):
|
| 144 |
+
try:
|
| 145 |
+
# Match "A(3,5)" or "A(3, 5)"
|
| 146 |
+
import re
|
| 147 |
+
m = re.match(r'([A-Za-z]+)\s*\(([^)]+)\)', str(raw_point_str))
|
| 148 |
+
if m:
|
| 149 |
+
lbl = m.group(1)
|
| 150 |
+
coords = m.group(2).split(",")
|
| 151 |
+
if len(coords) == 2:
|
| 152 |
+
px = Rational(coords[0].strip())
|
| 153 |
+
py = Rational(coords[1].strip())
|
| 154 |
+
pt = Point(name=lbl, x=px, y=py)
|
| 155 |
+
parsed_points[lbl] = pt
|
| 156 |
+
anchor.points.append(pt)
|
| 157 |
+
except Exception as e:
|
| 158 |
+
logger.debug(f"[GEO-SANITY] Could not parse points entry '{raw_point_str}': {e}")
|
| 159 |
+
|
| 160 |
+
# โโโ 3. Run point-on-circle checks โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 161 |
+
if circle:
|
| 162 |
+
for name, pt in parsed_points.items():
|
| 163 |
+
check_point_on_circle(pt, circle, anchor)
|
| 164 |
+
|
| 165 |
+
# โโโ 4. Diameter check: if exactly 2 points, check if they form a diameter โ
|
| 166 |
+
if circle and len(parsed_points) >= 2:
|
| 167 |
+
names = list(parsed_points.keys())
|
| 168 |
+
for i in range(len(names)):
|
| 169 |
+
for j in range(i + 1, len(names)):
|
| 170 |
+
p_i = parsed_points[names[i]]
|
| 171 |
+
p_j = parsed_points[names[j]]
|
| 172 |
+
seg = Segment(p1=p_i, p2=p_j)
|
| 173 |
+
check_is_diameter(seg, circle, anchor)
|
| 174 |
+
anchor.segments.append(seg)
|
| 175 |
+
|
| 176 |
+
# โโโ 5. Final summary log โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 177 |
+
logger.info(
|
| 178 |
+
f"[GEO-SANITY] โ
Check complete. "
|
| 179 |
+
f"Facts: {len(anchor.verified_facts)}, Warnings: {len(anchor.warnings)}"
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
prompt_block = _build_prompt_block(anchor)
|
| 183 |
+
return anchor, prompt_block
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def build_verified_facts_prompt(anchor: GeometryAnchor) -> str:
|
| 187 |
+
"""
|
| 188 |
+
Public helper: Re-builds the prompt block from an already-populated anchor.
|
| 189 |
+
Useful for re-injection after additional checks.
|
| 190 |
+
"""
|
| 191 |
+
return _build_prompt_block(anchor)
|
geometry_types.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# geometry_types.py - V1.0 (Geometric Sanity Engine โ Type Contracts)
|
| 2 |
+
# CTO NOTE: All coordinate arithmetic uses SymPy Rational to guarantee
|
| 3 |
+
# exact arithmetic โ no float drift, no rounding surprises.
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from sympy import Rational, simplify, sqrt
|
| 7 |
+
import logging
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass
|
| 13 |
+
class Point:
|
| 14 |
+
name: str
|
| 15 |
+
x: object # SymPy expression or numeric
|
| 16 |
+
y: object
|
| 17 |
+
|
| 18 |
+
def __eq__(self, other):
|
| 19 |
+
if not isinstance(other, Point):
|
| 20 |
+
return False
|
| 21 |
+
return (simplify(self.x - other.x) == 0 and
|
| 22 |
+
simplify(self.y - other.y) == 0)
|
| 23 |
+
|
| 24 |
+
def __repr__(self):
|
| 25 |
+
return f"{self.name}({self.x}, {self.y})"
|
| 26 |
+
|
| 27 |
+
def distance_to(self, other: "Point"):
|
| 28 |
+
"""Exact SymPy distance between two points."""
|
| 29 |
+
return sqrt((self.x - other.x)**2 + (self.y - other.y)**2)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass
|
| 33 |
+
class Line:
|
| 34 |
+
"""Represents the line ax + by + c = 0."""
|
| 35 |
+
a: object
|
| 36 |
+
b: object
|
| 37 |
+
c: object
|
| 38 |
+
|
| 39 |
+
def y_at(self, x_val):
|
| 40 |
+
"""Compute y given x (assuming b โ 0)."""
|
| 41 |
+
if simplify(self.b) == 0:
|
| 42 |
+
raise ValueError("[Line.y_at] Vertical line โ b=0, y is undefined for a given x.")
|
| 43 |
+
return Rational(-self.a * x_val - self.c, self.b)
|
| 44 |
+
|
| 45 |
+
def contains(self, p: "Point") -> bool:
|
| 46 |
+
"""Returns True if point p satisfies ax + by + c = 0."""
|
| 47 |
+
val = simplify(self.a * p.x + self.b * p.y + self.c)
|
| 48 |
+
return val == 0
|
| 49 |
+
|
| 50 |
+
def __repr__(self):
|
| 51 |
+
return f"Line({self.a}x + {self.b}y + {self.c} = 0)"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@dataclass
|
| 55 |
+
class Segment:
|
| 56 |
+
p1: Point
|
| 57 |
+
p2: Point
|
| 58 |
+
|
| 59 |
+
def midpoint(self) -> Point:
|
| 60 |
+
"""Returns the midpoint with exact rational arithmetic."""
|
| 61 |
+
from sympy import Rational as R
|
| 62 |
+
mid_x = (self.p1.x + self.p2.x) / 2
|
| 63 |
+
mid_y = (self.p1.y + self.p2.y) / 2
|
| 64 |
+
return Point(
|
| 65 |
+
name=f"mid_{self.p1.name}{self.p2.name}",
|
| 66 |
+
x=simplify(mid_x),
|
| 67 |
+
y=simplify(mid_y)
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
def length(self):
|
| 71 |
+
"""Exact SymPy length of the segment."""
|
| 72 |
+
return self.p1.distance_to(self.p2)
|
| 73 |
+
|
| 74 |
+
def __repr__(self):
|
| 75 |
+
return f"Segment({self.p1.name}{self.p2.name})"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@dataclass
|
| 79 |
+
class Circle:
|
| 80 |
+
center: Point
|
| 81 |
+
radius: object # SymPy expression (can be symbolic)
|
| 82 |
+
|
| 83 |
+
def contains(self, p: Point) -> bool:
|
| 84 |
+
"""Returns True if p lies exactly on the circle boundary."""
|
| 85 |
+
lhs = (p.x - self.center.x)**2 + (p.y - self.center.y)**2
|
| 86 |
+
return simplify(lhs - self.radius**2) == 0
|
| 87 |
+
|
| 88 |
+
def __repr__(self):
|
| 89 |
+
return f"Circle(center={self.center}, r={self.radius})"
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@dataclass
|
| 93 |
+
class GeometryAnchor:
|
| 94 |
+
"""
|
| 95 |
+
V1.0: The single source of truth for verified geometric facts.
|
| 96 |
+
Populated by `constraint_checks.py` and consumed by `geometric_sanity.py`.
|
| 97 |
+
"""
|
| 98 |
+
points: list = field(default_factory=list)
|
| 99 |
+
lines: list = field(default_factory=list)
|
| 100 |
+
segments: list = field(default_factory=list)
|
| 101 |
+
circles: list = field(default_factory=list)
|
| 102 |
+
verified_facts: list = field(default_factory=list)
|
| 103 |
+
warnings: list = field(default_factory=list)
|
| 104 |
+
|
| 105 |
+
def add_fact(self, fact: str):
|
| 106 |
+
logger.info(f"[GEO-ANCHOR] โ FACT: {fact}")
|
| 107 |
+
self.verified_facts.append(fact)
|
| 108 |
+
|
| 109 |
+
def add_warning(self, warning: str):
|
| 110 |
+
logger.warning(f"[GEO-ANCHOR] โ WARNING: {warning}")
|
| 111 |
+
self.warnings.append(warning)
|
| 112 |
+
|
| 113 |
+
def is_clean(self) -> bool:
|
| 114 |
+
"""True if no geometric contradictions were detected."""
|
| 115 |
+
return len(self.warnings) == 0
|
| 116 |
+
|
| 117 |
+
def summary(self) -> dict:
|
| 118 |
+
return {
|
| 119 |
+
"verified_facts": self.verified_facts,
|
| 120 |
+
"warnings": self.warnings,
|
| 121 |
+
"has_contradictions": not self.is_clean()
|
| 122 |
+
}
|
orchestrator.py
CHANGED
|
@@ -6,6 +6,7 @@ import logging, re
|
|
| 6 |
import ocr_strip_engine # V300: Stitch & Strip OCR engine
|
| 7 |
from utils.safe_json import safe_extract_json # V1.0: Canonical JSON extractor
|
| 8 |
from domain.math_validator import MathPolygraph # V1.0: SymPy Polygraph
|
|
|
|
| 9 |
from domain.processing_strategy import ProcessingStrategy
|
| 10 |
from domain.ontology import get_allowed_concepts, get_pedagogical_tag
|
| 11 |
from domain.math_normalizer import MathCanonicalizer
|
|
@@ -2357,6 +2358,20 @@ ctx.finish("$$ 4 $$", "ืืขืืื! ืืืขื ื ืืชืืฆืื.")
|
|
| 2357 |
if image_data and data_anchor:
|
| 2358 |
print("๐ก๏ธ [V8.9.2] Starting Data Anchor Validation Pass...")
|
| 2359 |
data_anchor = await self._validate_anchor(data_anchor, image_data, problem_text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2360 |
|
| 2361 |
# Iterate through the streaming smart_solve
|
| 2362 |
# V5.10.2: Remove keys already passed explicitly to avoid TypeError collision
|
|
|
|
| 6 |
import ocr_strip_engine # V300: Stitch & Strip OCR engine
|
| 7 |
from utils.safe_json import safe_extract_json # V1.0: Canonical JSON extractor
|
| 8 |
from domain.math_validator import MathPolygraph # V1.0: SymPy Polygraph
|
| 9 |
+
from geometric_sanity import run_geometric_sanity # V1.0: Geometric Sanity Engine
|
| 10 |
from domain.processing_strategy import ProcessingStrategy
|
| 11 |
from domain.ontology import get_allowed_concepts, get_pedagogical_tag
|
| 12 |
from domain.math_normalizer import MathCanonicalizer
|
|
|
|
| 2358 |
if image_data and data_anchor:
|
| 2359 |
print("๐ก๏ธ [V8.9.2] Starting Data Anchor Validation Pass...")
|
| 2360 |
data_anchor = await self._validate_anchor(data_anchor, image_data, problem_text)
|
| 2361 |
+
|
| 2362 |
+
# V1.0: GEOMETRIC SANITY CHECK (Ground Truth Injection)
|
| 2363 |
+
# Runs BEFORE the LLM to verify algebraic consistency of the extracted data.
|
| 2364 |
+
# Injects 'verified_facts' and 'geometry_warnings' into the anchor.
|
| 2365 |
+
try:
|
| 2366 |
+
_geo_anchor, _geo_prompt_block = run_geometric_sanity(data_anchor)
|
| 2367 |
+
if _geo_anchor.verified_facts or _geo_anchor.warnings:
|
| 2368 |
+
data_anchor["_verified_facts"] = _geo_anchor.verified_facts
|
| 2369 |
+
data_anchor["_geometry_warnings"] = _geo_anchor.warnings
|
| 2370 |
+
data_anchor["_geo_prompt_block"] = _geo_prompt_block
|
| 2371 |
+
print(f"๐ฌ [GEO-SANITY] Injected {len(_geo_anchor.verified_facts)} fact(s), "
|
| 2372 |
+
f"{len(_geo_anchor.warnings)} warning(s) into data_anchor.")
|
| 2373 |
+
except Exception as _geo_err:
|
| 2374 |
+
logging.warning(f"[GEO-SANITY] Non-fatal error: {_geo_err}")
|
| 2375 |
|
| 2376 |
# Iterate through the streaming smart_solve
|
| 2377 |
# V5.10.2: Remove keys already passed explicitly to avoid TypeError collision
|
prompts.py
CHANGED
|
@@ -322,7 +322,13 @@ def get_specialist_prompt(category, problem_text, solver_hint, grade, student_na
|
|
| 322 |
|
| 323 |
# Anchor Block โ DATA INTEGRITY RULE
|
| 324 |
anchor_block = ""
|
|
|
|
| 325 |
if data_anchor:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
anchor_block = f"""
|
| 327 |
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 328 |
๐ DATA INTEGRITY RULE (ABSOLUTE TRUTH):
|
|
@@ -338,6 +344,9 @@ def get_specialist_prompt(category, problem_text, solver_hint, grade, student_na
|
|
| 338 |
|
| 339 |
CONSTRAINT: If the data says A(0,5), use A(0,5). If it contains f(x), solve that f(x).
|
| 340 |
"""
|
|
|
|
|
|
|
|
|
|
| 341 |
|
| 342 |
# V231.5: Gender-aware phrases
|
| 343 |
if student_gender == "F":
|
|
@@ -417,6 +426,7 @@ def get_specialist_prompt(category, problem_text, solver_hint, grade, student_na
|
|
| 417 |
TONE: {features['tone']}
|
| 418 |
|
| 419 |
{anchor_block}
|
|
|
|
| 420 |
|
| 421 |
๐ฏ ืืืฉืืื: ืคืชืืจ ืืช ืืชืจืืื ืืฆืืจื ืคืืืืืืช, ืืขืฆืืื ืืืฉืืืื ืืจืืจืื.
|
| 422 |
|
|
|
|
| 322 |
|
| 323 |
# Anchor Block โ DATA INTEGRITY RULE
|
| 324 |
anchor_block = ""
|
| 325 |
+
geo_verified_block = "" # V1.0: Geometric Sanity Engine output
|
| 326 |
if data_anchor:
|
| 327 |
+
# Extract and remove internal sanity keys before JSON serialization
|
| 328 |
+
geo_prompt_block = data_anchor.pop("_geo_prompt_block", "")
|
| 329 |
+
data_anchor.pop("_verified_facts", None)
|
| 330 |
+
data_anchor.pop("_geometry_warnings", None)
|
| 331 |
+
|
| 332 |
anchor_block = f"""
|
| 333 |
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 334 |
๐ DATA INTEGRITY RULE (ABSOLUTE TRUTH):
|
|
|
|
| 344 |
|
| 345 |
CONSTRAINT: If the data says A(0,5), use A(0,5). If it contains f(x), solve that f(x).
|
| 346 |
"""
|
| 347 |
+
# V1.0: Inject verified geometric facts (if the sanity engine found anything)
|
| 348 |
+
if geo_prompt_block:
|
| 349 |
+
geo_verified_block = geo_prompt_block
|
| 350 |
|
| 351 |
# V231.5: Gender-aware phrases
|
| 352 |
if student_gender == "F":
|
|
|
|
| 426 |
TONE: {features['tone']}
|
| 427 |
|
| 428 |
{anchor_block}
|
| 429 |
+
{geo_verified_block}
|
| 430 |
|
| 431 |
๐ฏ ืืืฉืืื: ืคืชืืจ ืืช ืืชืจืืื ืืฆืืจื ืคืืืืืืช, ืืขืฆืืื ืืืฉืืืื ืืจืืจืื.
|
| 432 |
|