dotandru commited on
Commit
4de02b8
ยท
1 Parent(s): 3091d31

feat: implement Geometric Sanity Engine (V1.0) and fix project ID in functions

Browse files
Files changed (6) hide show
  1. .gcloudignore +16 -0
  2. constraint_checks.py +159 -0
  3. geometric_sanity.py +191 -0
  4. geometry_types.py +122 -0
  5. orchestrator.py +15 -0
  6. 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