dotandru commited on
Commit
a41344e
·
2 Parent(s): 4de02b85e14f93

feat: Universal Logic V2.0 — Ground Truth Architecture [22/22 tests]

Browse files
backend/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # backend/__init__.py
2
+ # BuddyMath V2.0 backend package
backend/extractor.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # backend/extractor.py
2
+ # BuddyMath V2.0 — Stage 1: Extraction LLM
3
+ #
4
+ # RULES (from spec):
5
+ # 1. Extract ONLY. Never solve.
6
+ # 2. If a value is unclear from OCR/text → return null. NO guessing.
7
+ # 3. `find` must list what the question asks, not intermediate steps.
8
+ # 4. `constraints` must list verbal/diagram constraints verbatim.
9
+ # ─────────────────────────────────────────────────────────────────────────
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import re
16
+ from typing import Any, Optional
17
+
18
+ import google.generativeai as genai
19
+
20
+ from backend.math_schema import MathProblemSchema
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # ── Extraction-only system prompt ────────────────────────────────────────
25
+ _EXTRACTION_SYSTEM_PROMPT = """
26
+ You are a MATH DATA EXTRACTOR. Your ONLY job is to parse the problem and output a strict JSON object.
27
+
28
+ ABSOLUTE RULES:
29
+ 1. DO NOT solve the problem. DO NOT calculate anything.
30
+ 2. If a value is unclear, ambiguous, or the OCR image is blurry → use JSON null for that field.
31
+ null ≠ "field not in problem". null = "field exists in problem but value is unreadable/unclear".
32
+ 3. Every field in `given` that the problem mentions must appear — even if its value is null.
33
+ 4. `find` must list exactly what the question asks to find/prove/calculate.
34
+ 5. `constraints` must list verbal/diagram rules (e.g. "M is the midpoint of AD").
35
+
36
+ OUTPUT FORMAT (strict JSON, no markdown, no explanation):
37
+ {
38
+ "problem_type": "<geometry|algebra|probability|statistics|trigonometry|calculus>",
39
+ "sub_type": "<e.g. circle, quadratic, normal_distribution>",
40
+ "given": {
41
+ "<label>": <value_or_null>
42
+ },
43
+ "find": ["<what to find 1>", "<what to find 2>"],
44
+ "constraints": ["<constraint 1>", "<constraint 2>"]
45
+ }
46
+ """.strip()
47
+
48
+
49
+ class Extractor:
50
+ """
51
+ Calls Gemini Flash to extract structured data from a math problem.
52
+ Never solves — only parses.
53
+ """
54
+
55
+ def __init__(self, model_name: str = "gemini-2.0-flash"):
56
+ self._model = genai.GenerativeModel(
57
+ model_name=model_name,
58
+ system_instruction=_EXTRACTION_SYSTEM_PROMPT,
59
+ )
60
+
61
+ def extract(
62
+ self,
63
+ problem_text: str,
64
+ image_data: Optional[bytes] = None,
65
+ ) -> MathProblemSchema:
66
+ """
67
+ Extract structured data from a math problem (text + optional image).
68
+
69
+ Args:
70
+ problem_text: The problem as plain text (from OCR or direct input).
71
+ image_data: Raw image bytes if the problem came from a photo.
72
+
73
+ Returns:
74
+ MathProblemSchema with verification_status="pending".
75
+ """
76
+ logger.info("📥 [EXTRACTOR] Starting extraction...")
77
+
78
+ # Build the prompt parts
79
+ parts: list[Any] = [problem_text]
80
+ if image_data:
81
+ parts.append({"mime_type": "image/jpeg", "data": image_data})
82
+
83
+ try:
84
+ response = self._model.generate_content(parts)
85
+ raw_text = response.text.strip()
86
+ logger.debug("📄 [EXTRACTOR] Raw LLM output:\n%s", raw_text)
87
+
88
+ parsed = self._parse_response(raw_text)
89
+ schema = MathProblemSchema(**parsed)
90
+
91
+ # Log null fields as warnings
92
+ null_keys = schema.null_fields()
93
+ if null_keys:
94
+ for k in null_keys:
95
+ msg = f"Null field after extraction: '{k}' — OCR unclear or ambiguous"
96
+ schema.warnings.append(msg)
97
+ logger.warning("⚠️ [EXTRACTOR] %s", msg)
98
+
99
+ logger.info(
100
+ "✅ [EXTRACTOR] Done. Type=%s/%s | Given=%d fields | Nulls=%s | Find=%s",
101
+ schema.problem_type,
102
+ schema.sub_type,
103
+ len(schema.given),
104
+ null_keys or "none",
105
+ schema.find,
106
+ )
107
+ return schema
108
+
109
+ except Exception as e:
110
+ logger.error("❌ [EXTRACTOR] Failed: %s", e)
111
+ raise
112
+
113
+ def _parse_response(self, raw: str) -> dict:
114
+ """
115
+ Strip markdown fences if present and parse JSON.
116
+ Raises ValueError on invalid JSON.
117
+ """
118
+ # Strip ```json ... ``` if the model wrapped it
119
+ clean = re.sub(r"^```(?:json)?\s*", "", raw, flags=re.IGNORECASE)
120
+ clean = re.sub(r"\s*```$", "", clean).strip()
121
+
122
+ try:
123
+ data = json.loads(clean)
124
+ except json.JSONDecodeError as e:
125
+ raise ValueError(f"Extractor returned invalid JSON: {e}\nRaw:\n{raw}") from e
126
+
127
+ return data
backend/math_schema.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # backend/math_schema.py
2
+ # BuddyMath V2.0 — The "Truth Anchor" Architecture
3
+ # Data Contract: MATH_PROBLEM_SCHEMA (Approved by Dotan, 2026-03-19)
4
+ #
5
+ # RULES:
6
+ # - `given` fields are set by the Extractor only. Never by the Verifier.
7
+ # - `verified` is set by the Verifier only. Never by the Extractor.
8
+ # - `null` in `given` means "field was expected but OCR was unclear" — NOT "field doesn't exist".
9
+ # - Only the pipeline (orchestrator) may change `verification_status`.
10
+ # ─────────────────────────────────────────────────────────────────────────
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any, Dict, List, Literal, Optional
15
+
16
+ from pydantic import BaseModel, Field
17
+
18
+
19
+ class MathProblemSchema(BaseModel):
20
+ """
21
+ The single source of truth for a parsed math problem.
22
+ Flows through: Extractor → Verifier → Pinner → LLM.
23
+ """
24
+
25
+ # ── What type of problem ────────────────────────────────────────────
26
+ problem_type: Literal[
27
+ "geometry",
28
+ "algebra",
29
+ "probability",
30
+ "statistics",
31
+ "trigonometry",
32
+ "calculus",
33
+ ]
34
+ sub_type: str # e.g. "circle", "quadratic", "normal_distribution"
35
+
36
+ # ── What is given (Extractor fills this) ────────────────────────────
37
+ # null value = "was expected but OCR/text was unclear" (not the same as absent key)
38
+ given: Dict[str, Any] = Field(
39
+ default_factory=dict,
40
+ description=(
41
+ "Key=label, Value=extracted value or null. "
42
+ "null means unclear, NOT missing from the problem."
43
+ ),
44
+ examples=[
45
+ {"A": [0, 1], "D": [0, 9], "radius": None},
46
+ {"equation": "x^2 - 5x + 6 = 0", "domain": None},
47
+ ],
48
+ )
49
+
50
+ # ── What the question asks to find ──────────────────────────────────
51
+ find: List[str] = Field(
52
+ default_factory=list,
53
+ description="What the question explicitly asks to calculate or prove.",
54
+ examples=[["center of circle", "radius"], ["roots of equation"]],
55
+ )
56
+
57
+ # ── Verbal constraints (from text / diagram labels) ─────────────────
58
+ constraints: List[str] = Field(
59
+ default_factory=list,
60
+ examples=[["AD is a diameter", "M is the center of the circle"]],
61
+ )
62
+
63
+ # ── Populated ONLY by the Verifier ──────────────────────────────────
64
+ verified: Dict[str, Any] = Field(
65
+ default_factory=dict,
66
+ description=(
67
+ "Algebraically proven values. "
68
+ "These are Ground Truth — the LLM must not re-derive them."
69
+ ),
70
+ )
71
+
72
+ # ── Pipeline status ─────────────────────────────────────────────────
73
+ verification_status: Literal[
74
+ "verified", # all `find` targets are algebraically confirmed
75
+ "partial", # some targets verified, others not
76
+ "contradiction", # algebraic result contradicts extracted data
77
+ "insufficient_data", # too many null fields to verify
78
+ "pending", # default before Verifier runs
79
+ ] = "pending"
80
+
81
+ warnings: List[str] = Field(
82
+ default_factory=list,
83
+ description="Non-fatal issues: OCR gaps, null substitutions, etc.",
84
+ )
85
+
86
+ class Config:
87
+ # Allow None values in `given` dict (explicit null support)
88
+ arbitrary_types_allowed = True
89
+
90
+ def null_fields(self) -> List[str]:
91
+ """Returns list of keys in `given` that are explicitly null."""
92
+ return [k for k, v in self.given.items() if v is None]
93
+
94
+ def is_ready_to_verify(self) -> bool:
95
+ """True if no critical null fields block verification."""
96
+ # Verifier can still run on partial data; it will set status accordingly
97
+ return True # Always try; Verifier decides the status
backend/pipeline.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # backend/pipeline.py
2
+ # BuddyMath V2.0 — Task 5: Pipeline Integration
3
+ #
4
+ # The full pipeline: OCR → EXTRACT → VERIFY → PIN → LLM
5
+ # All 4 stages are logged to console for every question.
6
+ #
7
+ # RED LINES (from spec):
8
+ # - Never deploy without logs from all pipeline stages.
9
+ # - The Solver is the existing LLM; this module only prepares its input.
10
+ # ─────────────────────────────────────────────────────────────────────────
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from dataclasses import dataclass, field
16
+ from typing import Optional
17
+
18
+ from backend.extractor import Extractor
19
+ from backend.math_schema import MathProblemSchema
20
+ from backend.universal_verifier import UniversalVerifier
21
+ from backend.variable_pinner import VariablePinner
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Singletons — instantiated once, reused across requests
26
+ _extractor = Extractor()
27
+ _verifier = UniversalVerifier()
28
+ _pinner = VariablePinner()
29
+
30
+
31
+ @dataclass
32
+ class PipelineResult:
33
+ """The complete output of one pipeline run, ready for the LLM."""
34
+ schema: MathProblemSchema
35
+ pin_block: str # inject this into the LLM prompt
36
+ stage_log: list[str] = field(default_factory=list)
37
+
38
+
39
+ def run_v2_pipeline(
40
+ problem_text: str,
41
+ image_data: Optional[bytes] = None,
42
+ ) -> PipelineResult:
43
+ """
44
+ Execute the full V2.0 Truth-Anchor pipeline.
45
+
46
+ Args:
47
+ problem_text: The math problem as plain text (post-OCR or raw).
48
+ image_data: Optional raw image bytes for OCR-aware extraction.
49
+
50
+ Returns:
51
+ PipelineResult with schema, pin_block, and stage_log.
52
+
53
+ Spec requirement:
54
+ Console log must show all 4 stages for every question.
55
+ """
56
+ log: list[str] = []
57
+
58
+ def _log(stage: int, name: str, detail: str) -> None:
59
+ msg = f"[PIPELINE] Stage {stage}/4 — {name}: {detail}"
60
+ log.append(msg)
61
+ logger.info(msg)
62
+
63
+ # ── Stage 1: OCR (already done upstream; we receive problem_text) ──
64
+ _log(1, "OCR/INPUT", f"Received problem ({len(problem_text)} chars, image={'yes' if image_data else 'no'})")
65
+
66
+ # ── Stage 2: EXTRACT ──────────────────────────────────────────────
67
+ schema = _extractor.extract(problem_text, image_data=image_data)
68
+ null_fields = schema.null_fields()
69
+ _log(
70
+ 2, "EXTRACT",
71
+ f"type={schema.problem_type}/{schema.sub_type} | given={list(schema.given.keys())} | "
72
+ f"nulls={null_fields or 'none'} | find={schema.find}"
73
+ )
74
+
75
+ # ── Stage 3: VERIFY ───────────────────────────────────────────────
76
+ schema = _verifier.verify(schema)
77
+ _log(
78
+ 3, "VERIFY",
79
+ f"status={schema.verification_status} | verified={list(schema.verified.keys())} | "
80
+ f"warnings={len(schema.warnings)}"
81
+ )
82
+ if schema.warnings:
83
+ for w in schema.warnings:
84
+ logger.warning("[PIPELINE] ⚠️ %s", w)
85
+
86
+ # ── Stage 4: PIN ──────────────────────────────────────────────────
87
+ pin_block = _pinner.build_pin_block(schema)
88
+ _log(
89
+ 4, "PIN",
90
+ f"pin_block_len={len(pin_block)} chars | status={schema.verification_status}"
91
+ )
92
+
93
+ # ── Summary log ───────────────────────────────────────────────────
94
+ logger.info(
95
+ "✅ [PIPELINE] Complete — %s | null=%s | pinned=%s | status=%s",
96
+ f"{schema.problem_type}/{schema.sub_type}",
97
+ null_fields or "none",
98
+ list(schema.verified.keys()),
99
+ schema.verification_status,
100
+ )
101
+
102
+ return PipelineResult(schema=schema, pin_block=pin_block, stage_log=log)
backend/tests/test_pipeline.py ADDED
@@ -0,0 +1,397 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # backend/tests/test_pipeline.py
2
+ # BuddyMath V2.0 — Acceptance Tests (v2 — Post Priority Fix)
3
+ #
4
+ # CRITICAL RULE (from review):
5
+ # M=(0,5) via midpoint of chord AD is the HALLUCINATION we are preventing.
6
+ # M=(3,5) via intersection of diameter lines is the ONLY correct answer.
7
+ #
8
+ # Run with: python -m pytest backend/tests/test_pipeline.py -v
9
+ # ─────────────────────────────────────────────────────────────────────────
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import sys
15
+ import os
16
+ import unittest
17
+
18
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
19
+
20
+ from backend.math_schema import MathProblemSchema
21
+ from backend.universal_verifier import UniversalVerifier
22
+ from backend.variable_pinner import VariablePinner
23
+
24
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
25
+
26
+ verifier = UniversalVerifier()
27
+ pinner = VariablePinner()
28
+
29
+
30
+ # ============================================================
31
+ # Task 2/3: Verifier robustness — always returns valid schema
32
+ # ============================================================
33
+ class TestVerifierRobustness(unittest.TestCase):
34
+
35
+ def test_circle_insufficient_data(self):
36
+ """All-null geometry → insufficient_data, never crashes."""
37
+ schema = MathProblemSchema(
38
+ problem_type="geometry",
39
+ sub_type="circle",
40
+ given={"A": None, "D": None, "radius": None},
41
+ find=["center of circle"],
42
+ constraints=["AD is a diameter"],
43
+ )
44
+ result = verifier.verify(schema)
45
+ self.assertIn(result.verification_status,
46
+ ["insufficient_data", "partial", "contradiction"])
47
+ self.assertIsInstance(result.warnings, list)
48
+
49
+ def test_circle_chord_midpoint_fallback(self):
50
+ """
51
+ FALLBACK ONLY test: A(0,1), D(0,9) named as diameter chord (no lines given).
52
+ Result must be M=(0.0, 5.0).
53
+ NOTE: This is the CORRECT result for this specific input.
54
+ It is WRONG to use midpoint when diameter lines are also available.
55
+ """
56
+ schema = MathProblemSchema(
57
+ problem_type="geometry",
58
+ sub_type="circle",
59
+ given={"A": [0, 1], "D": [0, 9]},
60
+ find=["center of circle", "radius"],
61
+ constraints=["AD is a diameter"],
62
+ )
63
+ result = verifier.verify(schema)
64
+ # Chord midpoint fallback is always partial — center is unconfirmed without line eqs
65
+ self.assertEqual(result.verification_status, "partial")
66
+ center = result.verified.get("center")
67
+ self.assertIsNotNone(center, "Center must be in verified")
68
+ self.assertAlmostEqual(center[0], 0.0, places=4)
69
+ self.assertAlmostEqual(center[1], 5.0, places=4)
70
+ self.assertAlmostEqual(result.verified.get("radius"), 4.0, places=4)
71
+ self.assertEqual(result.verified.get("verification_method"), "chord_midpoint_fallback")
72
+
73
+ def test_algebra_insufficient_data(self):
74
+ schema = MathProblemSchema(
75
+ problem_type="algebra",
76
+ sub_type="quadratic",
77
+ given={"equation": None},
78
+ find=["roots"],
79
+ constraints=[],
80
+ )
81
+ result = verifier.verify(schema)
82
+ self.assertEqual(result.verification_status, "insufficient_data")
83
+
84
+ def test_algebra_quadratic(self):
85
+ schema = MathProblemSchema(
86
+ problem_type="algebra",
87
+ sub_type="quadratic",
88
+ given={"equation": "x**2 - 5*x + 6"},
89
+ find=["roots"],
90
+ constraints=[],
91
+ )
92
+ result = verifier.verify(schema)
93
+ self.assertEqual(result.verification_status, "verified")
94
+ solutions = result.verified.get("solutions", [])
95
+ self.assertIn("2", solutions)
96
+ self.assertIn("3", solutions)
97
+
98
+ def test_probability_sum_to_one(self):
99
+ schema = MathProblemSchema(
100
+ problem_type="probability",
101
+ sub_type="basic",
102
+ given={"probabilities": [0.2, 0.3, 0.5]},
103
+ find=["verify distribution"],
104
+ constraints=[],
105
+ )
106
+ result = verifier.verify(schema)
107
+ self.assertEqual(result.verification_status, "verified")
108
+
109
+ def test_probability_contradiction(self):
110
+ schema = MathProblemSchema(
111
+ problem_type="probability",
112
+ sub_type="basic",
113
+ given={"probabilities": [0.5, 0.6]},
114
+ find=["verify distribution"],
115
+ constraints=[],
116
+ )
117
+ result = verifier.verify(schema)
118
+ self.assertEqual(result.verification_status, "contradiction")
119
+
120
+ def test_statistics_verified(self):
121
+ schema = MathProblemSchema(
122
+ problem_type="statistics",
123
+ sub_type="descriptive",
124
+ given={"data": [2, 4, 6]},
125
+ find=["mean"],
126
+ constraints=[],
127
+ )
128
+ result = verifier.verify(schema)
129
+ self.assertAlmostEqual(result.verified.get("mean"), 4.0, places=4)
130
+
131
+ def test_calculus_derivative(self):
132
+ schema = MathProblemSchema(
133
+ problem_type="calculus",
134
+ sub_type="differentiation",
135
+ given={"function": "x**2", "point": 3},
136
+ find=["derivative at x=3"],
137
+ constraints=[],
138
+ )
139
+ result = verifier.verify(schema)
140
+ self.assertAlmostEqual(result.verified.get("derivative_at_point"), 6.0, places=4)
141
+
142
+
143
+ # ============================================================
144
+ # THE KEY TEST — Task 4 "M=3,5" Acceptance Criterion
145
+ # ============================================================
146
+ class TestDiameterLineIntersection(unittest.TestCase):
147
+ """
148
+ This is THE acceptance test.
149
+
150
+ Problem: A(0,1), D(0,9)
151
+ Two diameters: y = 4/3*x + 1 and y = -4/3*x + 9
152
+ Algebraic intersection: x=3, y=5 → M=(3,5)
153
+
154
+ ❌ WRONG (hallucination): M=(0,5) — midpoint of chord AD
155
+ ✅ CORRECT (algebraic): M=(3,5) — intersection of diameter lines
156
+ """
157
+
158
+ def _make_schema(self) -> MathProblemSchema:
159
+ return MathProblemSchema(
160
+ problem_type="geometry",
161
+ sub_type="circle",
162
+ given={
163
+ "A": [0, 1],
164
+ "D": [0, 9],
165
+ "diameter_lines": ["y=4/3*x+1", "y=-4/3*x+9"],
166
+ },
167
+ find=["center of circle M"],
168
+ constraints=["AC and DB are diameters", "M is the center"],
169
+ )
170
+
171
+ def test_center_is_3_5_not_0_5(self):
172
+ """
173
+ ACCEPTANCE TEST: M must be (3,5) — intersection of diameters.
174
+ M=(0,5) means the verifier used midpoint of chord AD. This is the hallucination.
175
+ """
176
+ schema = self._make_schema()
177
+ result = verifier.verify(schema)
178
+
179
+ M = result.verified.get("M")
180
+ self.assertIsNotNone(M, "M must be in verified dict")
181
+ self.assertAlmostEqual(M[0], 3.0, places=4,
182
+ msg=f"M.x should be 3.0 (got {M[0]}) — midpoint fallback not allowed here")
183
+ self.assertAlmostEqual(M[1], 5.0, places=4,
184
+ msg=f"M.y should be 5.0 (got {M[1]})")
185
+
186
+ # Explicitly assert the WRONG answer doesn't appear
187
+ self.assertFalse(
188
+ abs(M[0] - 0.0) < 0.01 and abs(M[1] - 5.0) < 0.01,
189
+ "CRITICAL FAILURE: M=(0,5) detected — this is the chord midpoint hallucination!"
190
+ )
191
+
192
+ def test_center_method_is_line_intersection(self):
193
+ """Verify the method field is set correctly."""
194
+ schema = self._make_schema()
195
+ result = verifier.verify(schema)
196
+ method = result.verified.get("center_method")
197
+ self.assertEqual(method, "diameter_line_intersection")
198
+
199
+ def test_verification_status_is_verified(self):
200
+ schema = self._make_schema()
201
+ result = verifier.verify(schema)
202
+ self.assertIn(result.verification_status, ["verified", "partial"])
203
+
204
+ def test_pin_block_contains_correct_center(self):
205
+ """The PIN block must show M=(3.0, 5.0), not (0.0, 5.0)."""
206
+ schema = self._make_schema()
207
+ result = verifier.verify(schema)
208
+ block = pinner.build_pin_block(result)
209
+
210
+ self.assertIn("GROUND TRUTH", block)
211
+ self.assertIn("3.0", block, "Pin block must contain x=3.0 for center M")
212
+ self.assertIn("5.0", block, "Pin block must contain y=5.0 for center M")
213
+
214
+ def test_pin_block_aggressive_on_contradiction(self):
215
+ """Pin block flags CONTRADICTION when center_method = chord_midpoint with lines available."""
216
+ schema = MathProblemSchema(
217
+ problem_type="geometry",
218
+ sub_type="circle",
219
+ given={
220
+ "A": [0, 1],
221
+ "D": [0, 9],
222
+ "diameter_lines": ["y=4/3*x+1", "y=-4/3*x+9"],
223
+ },
224
+ find=["center"],
225
+ constraints=["AC and DB are diameters"],
226
+ # Simulate the hallucination being injected from outside
227
+ verified={"M": (0.0, 5.0), "center_method": "diameter_chord_midpoint"},
228
+ verification_status="contradiction",
229
+ warnings=["CRITICAL CONTRADICTION: LLM assumed M(0,5) from chord, correct is M(3,5)"],
230
+ )
231
+ block = pinner.build_pin_block(schema)
232
+ self.assertIn("CONTRADICTION", block)
233
+
234
+
235
+ # ============================================================
236
+ # Task 4: Pin block structure
237
+ # ============================================================
238
+ class TestVariablePinner(unittest.TestCase):
239
+
240
+ def test_pin_block_contains_ground_truth_header(self):
241
+ schema = MathProblemSchema(
242
+ problem_type="geometry",
243
+ sub_type="circle",
244
+ given={"A": [0, 1], "D": [0, 9]},
245
+ find=["center"],
246
+ constraints=["AD is a diameter"],
247
+ verified={"center": (0.0, 5.0), "radius": 4.0},
248
+ verification_status="verified",
249
+ )
250
+ block = pinner.build_pin_block(schema)
251
+ self.assertIn("GROUND TRUTH", block)
252
+ self.assertIn("center", block)
253
+ self.assertIn("radius", block)
254
+
255
+ def test_pin_block_null_warning(self):
256
+ schema = MathProblemSchema(
257
+ problem_type="geometry",
258
+ sub_type="circle",
259
+ given={"radius": None},
260
+ find=["center"],
261
+ constraints=[],
262
+ verification_status="insufficient_data",
263
+ )
264
+ block = pinner.build_pin_block(schema)
265
+ self.assertIn("INSUFFICIENT_DATA", block)
266
+ self.assertIn("radius", block)
267
+
268
+ def test_pin_block_contradiction_warning(self):
269
+ schema = MathProblemSchema(
270
+ problem_type="algebra",
271
+ sub_type="quadratic",
272
+ given={"equation": "x**2 - 5*x + 6", "solution": 99},
273
+ find=["roots"],
274
+ constraints=[],
275
+ verification_status="contradiction",
276
+ )
277
+ block = pinner.build_pin_block(schema)
278
+ self.assertIn("CONTRADICTION", block)
279
+
280
+
281
+ if __name__ == "__main__":
282
+ unittest.main(verbosity=2)
283
+
284
+
285
+ # ============================================================
286
+ # Phase C — 5-Domain Acceptance Suite
287
+ # Each test verifies: (1) correct result, (2) verification_method field
288
+ # ============================================================
289
+ class TestPhaseCDomainCoverage(unittest.TestCase):
290
+ """
291
+ Phase C acceptance criterion:
292
+ Each domain must report WHAT was verified AND HOW (verification_method).
293
+ 5 exam-style exercises, one per remaining domain.
294
+ """
295
+
296
+ # 🔴 ALGEBRA: substitution into original equation
297
+ def test_algebra_cubic_roots_and_method(self):
298
+ """x^3 - 6x^2 + 11x - 6 = 0 → roots [1, 2, 3]"""
299
+ schema = MathProblemSchema(
300
+ problem_type="algebra",
301
+ sub_type="cubic",
302
+ given={"equation": "x**3 - 6*x**2 + 11*x - 6"},
303
+ find=["roots"],
304
+ constraints=[],
305
+ )
306
+ result = verifier.verify(schema)
307
+ self.assertEqual(result.verification_status, "verified")
308
+ solutions = result.verified.get("solutions", [])
309
+ self.assertIn("1", solutions)
310
+ self.assertIn("2", solutions)
311
+ self.assertIn("3", solutions)
312
+ self.assertEqual(result.verified.get("verification_method"), "sympy_solve_substitution")
313
+ print(f" [algebra] method={result.verified.get('verification_method')}")
314
+
315
+ # 🔴 GEOMETRY: diameter line intersection (already covered, here as Phase C entry)
316
+ def test_geometry_circle_diameter_intersection_and_method(self):
317
+ """y=2x+1 ∩ y=-2x+9 → center (2, 5)"""
318
+ schema = MathProblemSchema(
319
+ problem_type="geometry",
320
+ sub_type="circle",
321
+ given={"diameter_lines": ["y=2*x+1", "y=-2*x+9"]},
322
+ find=["center"],
323
+ constraints=["given lines are diameters"],
324
+ )
325
+ result = verifier.verify(schema)
326
+ self.assertIn(result.verification_status, ["verified", "partial"])
327
+ M = result.verified.get("M")
328
+ self.assertIsNotNone(M)
329
+ self.assertAlmostEqual(M[0], 2.0, places=3)
330
+ self.assertAlmostEqual(M[1], 5.0, places=3)
331
+ self.assertEqual(result.verified.get("verification_method"), "diameter_line_intersection")
332
+ print(f" [geometry] M={M} method={result.verified.get('verification_method')}")
333
+
334
+ # 🟡 PROBABILITY: sum-to-1 check
335
+ def test_probability_three_event_distribution_and_method(self):
336
+ """P(A)=1/3, P(B)=1/2, P(C)=1/6 → sum=1"""
337
+ schema = MathProblemSchema(
338
+ problem_type="probability",
339
+ sub_type="discrete_distribution",
340
+ given={"probabilities": ["1/3", "1/2", "1/6"]},
341
+ find=["verify distribution is valid"],
342
+ constraints=["A, B, C are mutually exclusive and exhaustive"],
343
+ )
344
+ result = verifier.verify(schema)
345
+ self.assertEqual(result.verification_status, "verified")
346
+ self.assertAlmostEqual(result.verified.get("sum"), 1.0, places=9)
347
+ self.assertEqual(result.verified.get("verification_method"), "sympy_rational_sum")
348
+ print(f" [probability] sum={result.verified.get('sum')} method={result.verified.get('verification_method')}")
349
+
350
+ # 🟡 STATISTICS: mean and std via NumPy
351
+ def test_statistics_exam_scores_and_method(self):
352
+ """Scores [70, 80, 90, 100] → mean=85, std=11.18"""
353
+ schema = MathProblemSchema(
354
+ problem_type="statistics",
355
+ sub_type="descriptive",
356
+ given={"data": [70, 80, 90, 100]},
357
+ find=["mean", "standard deviation"],
358
+ constraints=[],
359
+ )
360
+ result = verifier.verify(schema)
361
+ self.assertEqual(result.verification_status, "verified")
362
+ self.assertAlmostEqual(result.verified.get("mean"), 85.0, places=4)
363
+ self.assertAlmostEqual(result.verified.get("std"), 11.1803, places=3)
364
+ self.assertEqual(result.verified.get("verification_method"), "numpy_population_stats")
365
+ print(f" [statistics] mean={result.verified.get('mean')} method={result.verified.get('verification_method')}")
366
+
367
+ # 🟢 CALCULUS: derivative at a point
368
+ def test_calculus_polynomial_derivative_and_method(self):
369
+ """f(x) = x^3 - 4x → f'(2) = 3*4 - 4 = 8"""
370
+ schema = MathProblemSchema(
371
+ problem_type="calculus",
372
+ sub_type="differentiation",
373
+ given={"function": "x**3 - 4*x", "point": 2},
374
+ find=["derivative at x=2"],
375
+ constraints=[],
376
+ )
377
+ result = verifier.verify(schema)
378
+ self.assertEqual(result.verification_status, "verified")
379
+ self.assertAlmostEqual(result.verified.get("derivative_at_point"), 8.0, places=4)
380
+ self.assertEqual(result.verified.get("verification_method"), "sympy_differentiation")
381
+ print(f" [calculus] f'(2)={result.verified.get('derivative_at_point')} method={result.verified.get('verification_method')}")
382
+
383
+ # 🟢 TRIGONOMETRY: identity verification
384
+ def test_trigonometry_pythagorean_identity_and_method(self):
385
+ """sin(x)**2 + cos(x)**2 = 1 → must simplify to 0 symbolically"""
386
+ schema = MathProblemSchema(
387
+ problem_type="trigonometry",
388
+ sub_type="identity",
389
+ given={"identity": "sin(x)**2 + cos(x)**2 = 1"},
390
+ find=["verify Pythagorean identity"],
391
+ constraints=[],
392
+ )
393
+ result = verifier.verify(schema)
394
+ self.assertEqual(result.verification_status, "verified")
395
+ self.assertTrue(result.verified.get("identity_valid"))
396
+ self.assertEqual(result.verified.get("verification_method"), "sympy_symbolic_simplification")
397
+ print(f" [trig] identity_valid={result.verified.get('identity_valid')} method={result.verified.get('verification_method')}")
backend/universal_verifier.py ADDED
@@ -0,0 +1,480 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # backend/universal_verifier.py
2
+ # BuddyMath V2.0 — Stage 2: SymPy-based Universal Verifier
3
+ #
4
+ # PRIORITY ORDER FOR CIRCLE CENTER (non-negotiable):
5
+ # 1. diameter_lines in given → center = algebraic intersection (ALWAYS preferred)
6
+ # 2. Midpoint of named diameter chord → FALLBACK ONLY (no lines available)
7
+ #
8
+ # A chord midpoint is NOT a center unless it is proven to be a diameter.
9
+ # If diameter line equations exist, they override ALL midpoint calculations.
10
+ # ─────────────────────────────────────────────────────────────────────────
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from typing import Any, Dict, List, Optional, Tuple
16
+
17
+ import sympy as sp
18
+
19
+ from backend.math_schema import MathProblemSchema
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ # ── Helpers ──────────────────────────────────────────────────────────────
25
+ def _to_point(value: Any) -> Optional[sp.Point2D]:
26
+ """Convert [x, y] list → sympy.Point2D, or None if invalid."""
27
+ if isinstance(value, (list, tuple)) and len(value) == 2:
28
+ try:
29
+ return sp.Point2D(sp.Rational(value[0]), sp.Rational(value[1]))
30
+ except Exception:
31
+ pass
32
+ return None
33
+
34
+
35
+ def _to_rational(value: Any) -> Optional[sp.Rational]:
36
+ """Convert numeric value → sympy.Rational, or None."""
37
+ if value is None:
38
+ return None
39
+ try:
40
+ return sp.Rational(value)
41
+ except Exception:
42
+ return None
43
+
44
+
45
+ # ── Domain dispatchers ────────────────────────────────────────────────────
46
+ class UniversalVerifier:
47
+ """
48
+ Routes a MathProblemSchema to the appropriate domain verifier.
49
+ Guaranteed to never raise an exception to the caller.
50
+ """
51
+
52
+ _DISPATCH: Dict[str, str] = {
53
+ "geometry": "_verify_geometry",
54
+ "algebra": "_verify_algebra",
55
+ "probability": "_verify_probability",
56
+ "statistics": "_verify_statistics",
57
+ "trigonometry": "_verify_trigonometry",
58
+ "calculus": "_verify_calculus",
59
+ }
60
+
61
+ def verify(self, schema: MathProblemSchema) -> MathProblemSchema:
62
+ """Entry point. Always returns a valid schema."""
63
+ logger.info(
64
+ "🔍 [VERIFIER] Starting. type=%s/%s | find=%s",
65
+ schema.problem_type, schema.sub_type, schema.find,
66
+ )
67
+ null_keys = schema.null_fields()
68
+ if null_keys:
69
+ logger.warning("⚠️ [VERIFIER] Null fields: %s", null_keys)
70
+
71
+ try:
72
+ method_name = self._DISPATCH.get(schema.problem_type)
73
+ if method_name:
74
+ getattr(self, method_name)(schema)
75
+ else:
76
+ schema.warnings.append(f"No verifier for problem_type='{schema.problem_type}'")
77
+ schema.verification_status = "partial"
78
+ except Exception as e:
79
+ logger.error("❌ [VERIFIER] Unexpected error: %s", e)
80
+ schema.warnings.append(f"Verifier internal error: {e}")
81
+ schema.verification_status = "partial"
82
+
83
+ logger.info(
84
+ "✅ [VERIFIER] Done. status=%s | verified=%s | warnings=%d",
85
+ schema.verification_status,
86
+ list(schema.verified.keys()),
87
+ len(schema.warnings),
88
+ )
89
+ return schema
90
+
91
+ # ── GEOMETRY ─────────────────────────────────────────────────────────
92
+ def _verify_geometry(self, schema: MathProblemSchema) -> None:
93
+ g = schema.given
94
+ verified = {}
95
+ sub = schema.sub_type.lower()
96
+ if "circle" in sub:
97
+ self._verify_circle(schema, g, verified)
98
+ elif any(k in sub for k in ("line", "linear", "segment")):
99
+ self._verify_linear(schema, g, verified)
100
+ else:
101
+ schema.warnings.append(f"No geometry handler for sub_type='{schema.sub_type}'")
102
+ schema.verification_status = "partial"
103
+
104
+ def _verify_circle(self, schema: MathProblemSchema, g: dict, verified: dict) -> None:
105
+ """
106
+ PRIORITY 1: diameter_lines → center = algebraic intersection.
107
+ PRIORITY 2: diameter chord → midpoint (only if no lines given).
108
+ """
109
+ import re as _re
110
+
111
+ # ═══════════════════════════════════════════════════════════════
112
+ # PRIORITY 1: Diameter line equations — MANDATORY if present
113
+ # ═════════════════════════════════════════════════════��═════════
114
+ diameter_lines = g.get("diameter_lines")
115
+ if diameter_lines and len(diameter_lines) >= 2:
116
+ logger.info(
117
+ "🔑 [VERIFIER/circle] diameter_lines detected — using intersection method"
118
+ )
119
+ center, method = _find_center_from_diameter_lines(diameter_lines, schema)
120
+ if center is not None:
121
+ cx, cy = float(center.x), float(center.y)
122
+ verified["M"] = (cx, cy)
123
+ verified["center"] = (cx, cy)
124
+ verified["center_method"] = "diameter_line_intersection"
125
+ verified["verification_method"] = "diameter_line_intersection"
126
+ schema.verified.update(verified)
127
+ logger.info(
128
+ "✅ [VERIFIER/circle] M = (%s, %s) via %s (method=diameter_line_intersection)",
129
+ cx, cy, method,
130
+ )
131
+
132
+ # Verify radius from a known point
133
+ _compute_radius_from_center(g, center, verified, schema)
134
+ schema.verified.update(verified)
135
+
136
+ # Cross-check if radius also given
137
+ given_r = g.get("radius")
138
+ if given_r and "radius" in verified:
139
+ if abs(float(given_r) - verified["radius"]) > 1e-6:
140
+ schema.warnings.append(
141
+ f"CONTRADICTION: given radius={given_r} vs "
142
+ f"computed={verified['radius']:.4f}"
143
+ )
144
+ schema.verification_status = "contradiction"
145
+ return
146
+
147
+ schema.verification_status = "partial" if schema.null_fields() else "verified"
148
+ return
149
+ else:
150
+ schema.warnings.append(
151
+ f"diameter_lines parsing/solve failed: {method} — falling back to chord"
152
+ )
153
+
154
+ # ═══════════════════════════════════════════════════════════════
155
+ # PRIORITY 2: Midpoint of diameter chord (FALLBACK)
156
+ # ═══════════════════════════════════════════════════════════════
157
+ points = {}
158
+ for k, v in g.items():
159
+ if v is None:
160
+ continue
161
+ p = _to_point(v)
162
+ if p:
163
+ points[k] = p
164
+
165
+ radius_raw = g.get("radius")
166
+ radius = _to_rational(radius_raw)
167
+
168
+ diameter_pairs: List[Tuple[str, str]] = []
169
+ has_diameter_constraint = False
170
+ for c in schema.constraints:
171
+ if "diameter" in c.lower():
172
+ has_diameter_constraint = True
173
+ labels = _re.findall(r"\b([A-Za-z]{1,2})\b", c)
174
+ matched = [lbl for lbl in labels if lbl in points]
175
+ if len(matched) >= 2:
176
+ diameter_pairs.append((matched[0], matched[1]))
177
+
178
+ # Auto-pick first 2 points if labels ambiguous
179
+ if not diameter_pairs and has_diameter_constraint and len(points) >= 2:
180
+ keys = list(points.keys())
181
+ diameter_pairs.append((keys[0], keys[1]))
182
+ logger.info(
183
+ "📐 [VERIFIER/circle/fallback] Using points %s, %s as diameter chord",
184
+ keys[0], keys[1],
185
+ )
186
+
187
+ if not diameter_pairs:
188
+ msg = "No diameter_lines and no named diameter chord — insufficient data"
189
+ schema.warnings.append(msg)
190
+ schema.verification_status = "insufficient_data"
191
+ return
192
+
193
+ for p1_label, p2_label in diameter_pairs:
194
+ p1, p2 = points[p1_label], points[p2_label]
195
+ cx = (p1.x + p2.x) / 2
196
+ cy = (p1.y + p2.y) / 2
197
+ center = sp.Point2D(cx, cy)
198
+ computed_radius = p1.distance(p2) / 2
199
+
200
+ verified["center"] = (float(cx), float(cy))
201
+ verified["M"] = (float(cx), float(cy))
202
+ verified["radius"] = float(computed_radius)
203
+ verified["center_method"] = "chord_midpoint_fallback"
204
+ verified["verification_method"] = "chord_midpoint_fallback"
205
+ schema.verified.update(verified)
206
+
207
+ logger.info(
208
+ "📐 [VERIFIER/circle/fallback] midpoint(%s,%s) → center=%s radius=%s [FALLBACK — status=partial]",
209
+ p1_label, p2_label, center, computed_radius,
210
+ )
211
+
212
+ if radius is not None and abs(float(computed_radius) - float(radius)) > 1e-6:
213
+ msg = (
214
+ f"CONTRADICTION: given radius={radius_raw} but "
215
+ f"computed from chord {p1_label}{p2_label}={float(computed_radius):.4f}"
216
+ )
217
+ schema.warnings.append(msg)
218
+ logger.error("🚨 [VERIFIER/circle] %s", msg)
219
+ schema.verification_status = "contradiction"
220
+ return
221
+
222
+ for label, pt in points.items():
223
+ if label in (p1_label, p2_label):
224
+ continue
225
+ dist = float(pt.distance(center))
226
+ r = float(computed_radius)
227
+ if abs(dist - r) < 1e-6:
228
+ verified[f"{label}_on_circle"] = True
229
+ else:
230
+ schema.warnings.append(
231
+ f"Point {label} NOT on circle (dist={dist:.4f}, r={r:.4f})"
232
+ )
233
+
234
+ schema.verified.update(verified)
235
+ # Chord midpoint fallback is always partial — center is unconfirmed without lines
236
+ if schema.verified.get("center_method") == "chord_midpoint_fallback":
237
+ schema.verification_status = "partial"
238
+ else:
239
+ schema.verification_status = "partial" if schema.null_fields() else "verified"
240
+
241
+ def _verify_linear(self, schema: MathProblemSchema, g: dict, verified: dict) -> None:
242
+ schema.warnings.append("Linear geometry verifier: basic — extend as needed")
243
+ schema.verification_status = "partial"
244
+
245
+ # ── ALGEBRA ──────────────────────────────────────────────────────────
246
+ def _verify_algebra(self, schema: MathProblemSchema) -> None:
247
+ g = schema.given
248
+ verified = schema.verified
249
+ equation_str = g.get("equation")
250
+ if equation_str is None:
251
+ schema.warnings.append("Algebra: 'equation' is null")
252
+ schema.verification_status = "insufficient_data"
253
+ return
254
+ try:
255
+ if "=" in str(equation_str):
256
+ lhs_s, rhs_s = str(equation_str).split("=", 1)
257
+ x = sp.Symbol("x")
258
+ expr = sp.sympify(lhs_s.strip()) - sp.sympify(rhs_s.strip())
259
+ else:
260
+ x = sp.Symbol("x")
261
+ expr = sp.sympify(str(equation_str))
262
+ solutions = sp.solve(expr, x)
263
+ verified["solutions"] = [str(s) for s in solutions]
264
+ verified["verification_method"] = "sympy_solve_substitution"
265
+ logger.info("📐 [VERIFIER/algebra] Solutions: %s (method=sympy_solve_substitution)", solutions)
266
+ given_solution = g.get("solution")
267
+ if given_solution is not None:
268
+ given_val = sp.Rational(given_solution)
269
+ if given_val not in solutions:
270
+ schema.warnings.append(
271
+ f"CONTRADICTION: given solution={given_solution} not in {solutions}"
272
+ )
273
+ schema.verification_status = "contradiction"
274
+ return
275
+ schema.verification_status = "partial" if schema.null_fields() else "verified"
276
+ except Exception as e:
277
+ schema.warnings.append(f"Algebra verifier error: {e}")
278
+ schema.verification_status = "partial"
279
+
280
+ # ── PROBABILITY ───────────────────────────────────────────────────────
281
+ def _verify_probability(self, schema: MathProblemSchema) -> None:
282
+ g = schema.given
283
+ verified = schema.verified
284
+ probs = g.get("probabilities")
285
+ if probs is None:
286
+ schema.warnings.append("Probability: 'probabilities' is null")
287
+ schema.verification_status = "insufficient_data"
288
+ return
289
+ try:
290
+ total = sum(sp.Rational(str(p)) for p in probs)
291
+ verified["sum"] = float(total)
292
+ verified["verification_method"] = "sympy_rational_sum"
293
+ logger.info("📐 [VERIFIER/probability] sum=%s (method=sympy_rational_sum)", float(total))
294
+ if total != 1:
295
+ schema.warnings.append(
296
+ f"CONTRADICTION: probabilities sum to {float(total):.4f}, not 1.0"
297
+ )
298
+ schema.verification_status = "contradiction"
299
+ else:
300
+ schema.verification_status = "verified"
301
+ except Exception as e:
302
+ schema.warnings.append(f"Probability verifier error: {e}")
303
+ schema.verification_status = "partial"
304
+
305
+ # ── STATISTICS ────────────────────────────────────────────────────────
306
+ def _verify_statistics(self, schema: MathProblemSchema) -> None:
307
+ import numpy as np
308
+ g = schema.given
309
+ verified = schema.verified
310
+ data = g.get("data")
311
+ if data is None:
312
+ schema.warnings.append("Statistics: 'data' is null")
313
+ schema.verification_status = "insufficient_data"
314
+ return
315
+ try:
316
+ arr = np.array([float(x) for x in data])
317
+ verified["mean"] = float(np.mean(arr))
318
+ verified["std"] = float(np.std(arr, ddof=0))
319
+ verified["verification_method"] = "numpy_population_stats"
320
+ logger.info(
321
+ "📐 [VERIFIER/statistics] mean=%.4f std=%.4f (method=numpy_population_stats)",
322
+ verified["mean"], verified["std"],
323
+ )
324
+ for key in ("mean", "std"):
325
+ given_val = g.get(key)
326
+ if given_val is not None and abs(float(given_val) - verified[key]) > 1e-6:
327
+ schema.warnings.append(
328
+ f"CONTRADICTION: given {key}={given_val} vs computed={verified[key]:.6f}"
329
+ )
330
+ schema.verification_status = "contradiction"
331
+ return
332
+ schema.verification_status = "verified"
333
+ except Exception as e:
334
+ schema.warnings.append(f"Statistics verifier error: {e}")
335
+ schema.verification_status = "partial"
336
+
337
+ # ── TRIGONOMETRY ──────────────────────────────────────────────────────
338
+ def _verify_trigonometry(self, schema: MathProblemSchema) -> None:
339
+ g = schema.given
340
+ verified = schema.verified
341
+ identity = g.get("identity")
342
+ angle_deg = g.get("angle_deg")
343
+ if identity is None:
344
+ schema.warnings.append("Trigonometry: 'identity' is null")
345
+ schema.verification_status = "insufficient_data"
346
+ return
347
+ try:
348
+ theta = sp.Symbol("theta")
349
+ lhs_s, rhs_s = str(identity).split("=", 1)
350
+ lhs = sp.sympify(lhs_s.strip())
351
+ rhs = sp.sympify(rhs_s.strip())
352
+ diff = sp.simplify(lhs - rhs)
353
+ if diff == 0:
354
+ verified["identity_valid"] = True
355
+ verified["verification_method"] = "sympy_symbolic_simplification"
356
+ logger.info("✅ [VERIFIER/trig] Identity valid (method=sympy_symbolic_simplification)")
357
+ schema.verification_status = "verified"
358
+ elif angle_deg is not None:
359
+ angle_rad = sp.Rational(angle_deg) * sp.pi / 180
360
+ val = float(diff.subs(theta, angle_rad))
361
+ verified["identity_at_angle"] = (abs(val) < 1e-9)
362
+ schema.verification_status = "partial" if abs(val) < 1e-9 else "contradiction"
363
+ else:
364
+ schema.warnings.append("Trig identity could not be simplified to zero")
365
+ schema.verification_status = "partial"
366
+ except Exception as e:
367
+ schema.warnings.append(f"Trigonometry verifier error: {e}")
368
+ schema.verification_status = "partial"
369
+
370
+ # ── CALCULUS ──────────────────────────────────────────────────────────
371
+ def _verify_calculus(self, schema: MathProblemSchema) -> None:
372
+ g = schema.given
373
+ verified = schema.verified
374
+ func_str = g.get("function")
375
+ point_raw = g.get("point")
376
+ if func_str is None:
377
+ schema.warnings.append("Calculus: 'function' is null")
378
+ schema.verification_status = "insufficient_data"
379
+ return
380
+ try:
381
+ x = sp.Symbol("x")
382
+ f = sp.sympify(str(func_str))
383
+ deriv = sp.diff(f, x)
384
+ verified["derivative"] = str(deriv)
385
+ verified["verification_method"] = "sympy_differentiation"
386
+ logger.info("📐 [VERIFIER/calculus] f'(x)=%s (method=sympy_differentiation)", deriv)
387
+ if point_raw is not None:
388
+ pt = _to_rational(point_raw)
389
+ val = float(deriv.subs(x, pt))
390
+ verified["derivative_at_point"] = val
391
+ given_dv = g.get("derivative_at_point")
392
+ if given_dv is not None and abs(float(given_dv) - val) > 1e-9:
393
+ schema.warnings.append(
394
+ f"CONTRADICTION: given f'({point_raw})={given_dv} vs computed={val:.6f}"
395
+ )
396
+ schema.verification_status = "contradiction"
397
+ return
398
+ schema.verification_status = "verified"
399
+ except Exception as e:
400
+ schema.warnings.append(f"Calculus verifier error: {e}")
401
+ schema.verification_status = "partial"
402
+
403
+
404
+ # ── Module-level helpers ──────────────────────────────────────────────────
405
+ def _find_center_from_diameter_lines(
406
+ diameter_lines: list,
407
+ schema: "MathProblemSchema",
408
+ ) -> Tuple[Optional[sp.Point2D], str]:
409
+ """
410
+ Solve the intersection of two diameter line equations.
411
+ Returns (sp.Point2D, description) or (None, reason).
412
+ """
413
+ if len(diameter_lines) < 2:
414
+ return None, "need at least 2 diameter lines"
415
+
416
+ x, y = sp.symbols("x y")
417
+
418
+ def _parse_line(eq_str: str) -> Optional[sp.Eq]:
419
+ eq_str = str(eq_str).strip().replace(" ", "")
420
+ if "=" not in eq_str:
421
+ return None
422
+ lhs_s, rhs_s = eq_str.split("=", 1)
423
+ try:
424
+ return sp.Eq(sp.sympify(lhs_s), sp.sympify(rhs_s))
425
+ except Exception as e:
426
+ logger.error("❌ [VERIFIER/diameters] Cannot parse '%s': %s", eq_str, e)
427
+ return None
428
+
429
+ eq1 = _parse_line(diameter_lines[0])
430
+ eq2 = _parse_line(diameter_lines[1])
431
+
432
+ if eq1 is None or eq2 is None:
433
+ return None, "line parsing failed"
434
+
435
+ logger.info(
436
+ "📐 [VERIFIER/diameters] Solving:\n L1=%s\n L2=%s", eq1, eq2
437
+ )
438
+
439
+ try:
440
+ sol = sp.solve([eq1, eq2], [x, y])
441
+ if not sol:
442
+ schema.warnings.append("Diameter lines are parallel — no intersection")
443
+ return None, "parallel lines"
444
+
445
+ if isinstance(sol, dict):
446
+ cx, cy = sol[x], sol[y]
447
+ elif isinstance(sol, (list, tuple)) and sol:
448
+ first = sol[0]
449
+ cx, cy = first[0], first[1]
450
+ else:
451
+ return None, "unexpected solution format"
452
+
453
+ center = sp.Point2D(cx, cy)
454
+ logger.info("✅ [VERIFIER/diameters] Center = (%s, %s)", cx, cy)
455
+ return center, f"intersection of {diameter_lines[0]} ∩ {diameter_lines[1]}"
456
+
457
+ except Exception as e:
458
+ logger.error("❌ [VERIFIER/diameters] Solve error: %s", e)
459
+ return None, f"solve error: {e}"
460
+
461
+
462
+ def _compute_radius_from_center(
463
+ g: dict, center: sp.Point2D, verified: dict, schema: "MathProblemSchema"
464
+ ) -> None:
465
+ """Compute radius as distance from center to the first known point."""
466
+ for k, v in g.items():
467
+ if k in ("diameter_lines", "radius"):
468
+ continue
469
+ if v is None:
470
+ continue
471
+ p = _to_point(v)
472
+ if p:
473
+ r = float(p.distance(center))
474
+ verified["radius"] = r
475
+ logger.info("📐 [VERIFIER/circle] radius = dist(%s, center) = %.4f", k, r)
476
+ return
477
+ # Fallback: use given radius
478
+ given_r = g.get("radius")
479
+ if given_r is not None:
480
+ verified["radius"] = float(given_r)
backend/variable_pinner.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # backend/variable_pinner.py
2
+ # BuddyMath V2.0 — Stage 3: Variable Pinning
3
+ #
4
+ # PURPOSE:
5
+ # Converts verified data from MathProblemSchema into a hard constraint block
6
+ # that is injected into the LLM's prompt as GROUND TRUTH.
7
+ #
8
+ # THE MESSAGE TO THE LLM (verbatim from spec):
9
+ # "The values below were verified algebraically. They are Ground Truth.
10
+ # Do NOT recalculate them. If your intuition contradicts them — the table is correct."
11
+ # ─────────────────────────────────────────────────────────────────────────
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from typing import Optional
17
+
18
+ from backend.math_schema import MathProblemSchema
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ _PIN_HEADER = """
23
+ ╔══════════════════════════════════════════════════════════════════════╗
24
+ ║ 🔒 ALGEBRAICALLY VERIFIED — GROUND TRUTH ║
25
+ ║ The values below were computed deterministically (SymPy). ║
26
+ ║ DO NOT recalculate them. DO NOT trust visual intuition over them. ║
27
+ ║ If your reasoning contradicts any value here — the table wins. ║
28
+ ╚══════════════════════════════════════════════════════════════════════╝
29
+ """.strip()
30
+
31
+ _NULL_WARNING = """
32
+ ⚠️ INSUFFICIENT_DATA WARNING:
33
+ The following fields could NOT be verified due to unclear OCR/input:
34
+ {null_list}
35
+ For these fields, reason carefully from the available constraints.
36
+ DO NOT invent values. State explicitly that these are assumed/estimated.
37
+ """.strip()
38
+
39
+ _CONTRADICTION_WARNING = """
40
+ 🚨 CONTRADICTION DETECTED:
41
+ The verifier found inconsistencies between the extracted data and
42
+ algebraic facts. Proceed with the ALGEBRAICALLY COMPUTED values,
43
+ NOT the extracted ones.
44
+ """.strip()
45
+
46
+
47
+ class VariablePinner:
48
+ """
49
+ Generates the pinning block that is injected into the LLM prompt
50
+ BEFORE the main pedagogical reasoning begins.
51
+ """
52
+
53
+ def build_pin_block(self, schema: MathProblemSchema) -> str:
54
+ """
55
+ Build the full pinning block string.
56
+
57
+ This string must be injected into the LLM prompt verbatim,
58
+ before the main solver / explainer instruction.
59
+
60
+ Returns:
61
+ str: Formatted pinning block ready for prompt injection.
62
+ """
63
+ lines: list[str] = [_PIN_HEADER, ""]
64
+
65
+ # ── Status-specific header ────────────────────────────────────
66
+ if schema.verification_status == "contradiction":
67
+ lines.append(_CONTRADICTION_WARNING)
68
+ lines.append("")
69
+ elif schema.verification_status == "insufficient_data":
70
+ null_keys = schema.null_fields()
71
+ null_list = "\n".join(f" • {k}" for k in null_keys)
72
+ lines.append(_NULL_WARNING.format(null_list=null_list))
73
+ lines.append("")
74
+
75
+ # ── Verified facts table ──────────────────────────────────────
76
+ if schema.verified:
77
+ lines.append("📌 PINNED VALUES (Ground Truth):")
78
+ for key, val in schema.verified.items():
79
+ lines.append(f" {key:30s} = {val}")
80
+ lines.append("")
81
+
82
+ # ── What to find ─────────────────────────────────────────────
83
+ if schema.find:
84
+ lines.append("🎯 QUESTION TARGETS (what to find):")
85
+ for target in schema.find:
86
+ lines.append(f" • {target}")
87
+ lines.append("")
88
+
89
+ # ── Constraints ───────────────────────────────────────────────
90
+ if schema.constraints:
91
+ lines.append("📋 PROBLEM CONSTRAINTS:")
92
+ for c in schema.constraints:
93
+ lines.append(f" • {c}")
94
+ lines.append("")
95
+
96
+ # ── Non-fatal warnings ────────────────────────────────────────
97
+ if schema.warnings:
98
+ lines.append("⚠️ WARNINGS (for LLM awareness):")
99
+ for w in schema.warnings:
100
+ lines.append(f" ! {w}")
101
+ lines.append("")
102
+
103
+ pin_block = "\n".join(lines)
104
+
105
+ logger.info(
106
+ "📌 [PINNER] Built pin block | status=%s | pinned_keys=%s | null_fields=%s",
107
+ schema.verification_status,
108
+ list(schema.verified.keys()),
109
+ schema.null_fields(),
110
+ )
111
+ logger.debug("📌 [PINNER] Full block:\n%s", pin_block)
112
+
113
+ return pin_block
docs/legacy_prompts_backup.txt ADDED
@@ -0,0 +1,937 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # prompts.py - V275.0 (Enhanced OCR for Nested Fractions)
2
+ # Based on BUDDYMATH_COMPLETE_GUIDE V230.8 §1.3, §4.1, §6.1
3
+ import json
4
+
5
+
6
+ def _get_grade_features(grade: str, category: str = "") -> dict:
7
+ """התאמת רמת הפירוט לפי יחידות לימוד (§5.1)"""
8
+ is_investigation = (category == "INVESTIGATION")
9
+
10
+ if "5 יח\"ל" in grade:
11
+ style = "הוכחה דקדקנית של כל מעבר, כולל נגזרות שנייה, קמירות ואסימפטוטות" if is_investigation else "הוכחה דקדקנית של כל מעבר ודיוק אלגברי מירבי"
12
+ return {
13
+ "depth": "אקדמי ומעמיק",
14
+ "style": style,
15
+ "tone": "מעצים ומאתגר, כביר של בגרות 5 יח\"ל"
16
+ }
17
+ elif "4 יח\"ל" in grade:
18
+ style = "הסבר ברור של חוקי האלגברה עם דגש על כלל שרשרת" if is_investigation else "הסבר ברור של חוקי האלגברה ופירוט תהליכי הפתרון"
19
+ return {
20
+ "depth": "מפורט ותומך",
21
+ "style": style,
22
+ "tone": "מעודד ומפורט, שלב אחר שלב"
23
+ }
24
+ else:
25
+ return {
26
+ "depth": "פשוט, ברור ומדובר 'בגובה העיניים' (מבחן הילד בן ה-16)",
27
+ "style": "צעד אחר צעד, בקריאה שוטפת. חובה להשתמש במשפטי קישור ומעבר מילוליים (כמו 'נציב את הנתונים בנוסחה:', 'כעת נבדוק את תחום ההגדרה:'). אסור להרצות.",
28
+ "tone": "חם, סבלני, מלא התלהבות ועידוד תמידי. כמו מורה אהובה שעוזרת באופן פרטי."
29
+ }
30
+
31
+ def _detect_relevant_rules(text: str, category: str = "") -> list:
32
+ """זיהוי כללים רלוונטיים לפי קטגוריה — מבוסס-הקשר (§4.1, V231.1)"""
33
+ rules = []
34
+ t = text.lower()
35
+
36
+ # === GEOMETRY rules (only for GEOMETRY category) ===
37
+ if category != "INVESTIGATION":
38
+ if any(x in t for x in ["מעגל", "מרכז", "רדיוס", "מקום גיאומטרי"]):
39
+ rules.append(r"משוואת מעגל: $(x-a)^2 + (y-b)^2 = r^2$")
40
+ rules.append(r"היקף מעגל: $2\\pi r$")
41
+ if "מרחק" in t or "d =" in t or "מקום גיאומטרי" in t:
42
+ rules.append(r"דיסטנס: $d = \\sqrt{(x_2-x_1)^2 + (y_2-y_1)^2}$")
43
+ if any(x in t for x in ["ישר", "שיפוע"]):
44
+ rules.append(r"משוואת ישר: $y = mx + b$")
45
+ if any(x in t for x in ["משולש", "שטח"]):
46
+ rules.append(r"שטח משולש: $S = \\frac{1}{2} \\cdot a \\cdot h$")
47
+ if any(x in t for x in ["משיק", "אנך"]):
48
+ rules.append(r"שיפועים אנכיים: $m_1 \\cdot m_2 = -1$")
49
+ if "מקום גיאומטרי" in t:
50
+ rules.append(r"פרבולה: מרחק מנקודה שווה למרחק מישר.")
51
+ rules.append(r"אליפסה: סכום מרחקים מ-2 נקודות קבוע ($d_1+d_2=k$).")
52
+ rules.append(r"היפרבולה: הפרש מרחקים מ-2 נקודות קבוע ($|d_1-d_2|=k$).")
53
+ if "מקום גיאומטרי" in t:
54
+ rules.append(r"⚠️ הנחיה קריטית: אל תנחש את הצורה! פיתוח המשוואה חייב להיעשות צעד-אחר-צעד מההגדרה המילולית.")
55
+ rules.append(r"דוגמה לשימוש בהגדרה: אם רשום שמרחק נקודה $P(x,y)$ ממיקום א' שווה למרחקה ממיקום ב', רשום משוואה מהצורה $d_1 = d_2$. הצב את נוסחאות המרחק, העלה בריבוע ופשט.")
56
+ rules.append(r"בביטויים עם שורשים: בודד שורש אחד -> עלה בריבוע -> בודד את השורש השני -> עלה בריבוע שוב.")
57
+
58
+ # === PROOF-specific rules (V282.0) ===
59
+ if any(x in t for x in ["הוכח", "הוכיח", "הוכיחו", "הראה כי", "הראי כי", "הוכח כי", "הוכיחי"]):
60
+ rules.append(r"⚠️ זוהי שאלת הוכחה! חובה להראות את כל שרשרת ההיגיון, לא רק את התוצאה.")
61
+ rules.append(r"מבנה הוכחה: נתון -> מסקנה ביניים (+ נימוק/משפט) -> מסקנה הבאה -> ... -> מ.ש.ל.")
62
+ rules.append(r"לכל מעבר לוגי חובה לציין את הנימוק: שם המשפט, התכונה, או הכלל.")
63
+ rules.append(r"משולש שווה שוקיים -> זוויות בסיס שוות, חוצה זווית = תיכון = גובה לבסיס.")
64
+ rules.append(r"משולשים חופפים: צ.צ.צ / צ.ז.צ / ז.צ.ז - ציין איזה קריטריון נבחר ולמה.")
65
+ rules.append(r"אם צריך להוכיח שוויון קטעים - חפש משולשים חופפים, או תכ��נות של צורות מיוחדות.")
66
+
67
+ rules.append(r"בגיאומטריה אנליטית: לפני חישוב, בצע 'בדיקת שפיות' (Sanity Check).")
68
+ rules.append(r"אם נתון ש-AC קוטר, המרכז M *חייב* להיות האמצע שלו. אם החישוב מראה אחרת - הנתונים שהוצאת שגויים! נסה לקרוא שוב.")
69
+ rules.append(r"עדיפות לנתונים: טקסט כתוב > משוואות > שרטוט ויזואלי.")
70
+ rules.append(r"הימנע משימוש ב-\\\\ בתוך בלוקים של מתמטיקה, השתמש בצעדים נפרדים.")
71
+
72
+ # V8.6.3: Contextual Logic Guardrails
73
+ if category == "GEOMETRY":
74
+ rules.append(r"ZERO ASSUMPTIONS RULE: NEVER assume, guess, or invent ANY geometric property (e.g., 'this segment is a diameter', 'this angle is 90 degrees', 'this triangle is isosceles') unless it is EXPLICITLY WRITTEN in the provided text. You are strictly forbidden from fabricating data or relationships just because the coordinates look convenient. Work ONLY with the exact facts stated in the OCR text and Data Anchor.")
75
+ rules.append(r"CRITICAL GEOMETRY RULE: You MUST strictly adhere to the geometric layout described in the problem text. If your calculation leads to a paradox (e.g., length 0, points colliding, or negative area), YOUR assumption is wrong. Recalculate based strictly on the given explicit constraints. DO NOT change the given equations.")
76
+
77
+ if category in ["CALCULUS", "FUNCTION_ANALYSIS", "INVESTIGATION"]:
78
+ rules.append(r"GRAPH MATCHING PROTOCOL: When asked to select a correct graph from multiple options (I, II, III, IV), you MUST first perform a visual scan: explicitly describe what you see in EACH given graph (e.g., 'Graph I shows asymptotes at... Graph II shows a hole at...'). Only AFTER describing all options, match your mathematical deductions to the correct visual description and select the final answer.")
79
+
80
+ if category == "CALCULUS":
81
+ if any(x in t for x in ["נגזרת", "גזור", "f'", "חקור"]):
82
+ rules.append(r"כלל החזקה: $(x^n)' = nx^{n-1}$")
83
+ if any(x in t for x in ["/", "frac", "מנה", "חילוק"]):
84
+ rules.append(r"נגזרת מנה: $(\\frac{u}{v})' = \\frac{u'v - uv'}{v^2}$")
85
+ if any(x in t for x in ["מכפלה", "כפל"]):
86
+ rules.append(r"נגזרת מכפלה: $(u \\cdot v)' = u'v + uv'$")
87
+ if any(x in t for x in ["שרשרת", "הרכבה", "sin", "cos"]):
88
+ rules.append(r"כלל שרשרת: $[f(g(x))]' = f'(g(x)) \\cdot g'(x)$")
89
+ if any(x in t for x in ["אינטגרל", "שטח מתחת"]):
90
+ rules.append(r"אינטגרל חזקה: $\\int x^n dx = \\frac{x^{n+1}}{n+1} + C$")
91
+ if any(x in t for x in ["דיפרנציאלית", "y'", "dy/dx"]):
92
+ rules.append(r"משוואה דיפרנציאלית: הפרד משתנים, אינטגרל משני הצדדים.")
93
+
94
+ # === SEQUENCES rules ===
95
+ if any(x in t for x in ["סדרה", "חשבונית", "הנדסית", "סכום חלקי", "הפרש"]):
96
+ rules.append(r"סדרה חשבונית: $a_n = a_1 + (n-1)d$, סכום: $S_n = \\frac{n}{2}(a_1 + a_n)$")
97
+ rules.append(r"סדרה הנדסית: $a_n = a_1 \\cdot q^{n-1}$, סכום: $S_n = a_1 \\cdot \\frac{q^n - 1}{q - 1}$")
98
+ if "אינסופי" in t or "גבול" in t:
99
+ rules.append(r"סכום סדרה הנדסית אינסופית ($|q|<1$): $S = \\frac{a_1}{1-q}$")
100
+
101
+ # === COMPLEX NUMBERS rules ===
102
+ if any(x in t for x in ["מרוכב", "מדומה", "i²", "i^2"]):
103
+ rules.append(r"$i^2 = -1$, מספר מרוכב: $z = a + bi$")
104
+ rules.append(r"מודולוס: $|z| = \\sqrt{a^2 + b^2}$, צמוד: $\\bar{z} = a - bi$")
105
+ if any(x in t for x in ["קוטבי", "דה-מואבר", "טריגונומטרי"]):
106
+ rules.append(r"צורה טריגונומטרית: $z = r(\\cos\\theta + i\\sin\\theta)$")
107
+ rules.append(r"דה-מואבר: $z^n = r^n(\\cos(n\\theta) + i\\sin(n\\theta))$")
108
+
109
+ # === VECTORS rules ===
110
+ if any(x in t for x in ["וקטור", "וקטורים"]):
111
+ rules.append(r"$\\vec{u} = (a,b)$, $|\\vec{u}| = \\sqrt{a^2+b^2}$")
112
+ rules.append(r"מכפלה סקלרית: $\\vec{u}\\cdot\\vec{v} = a_1 a_2 + b_1 b_2 = |u||v|\\cos\\alpha$")
113
+ rules.append(r"וקטורים מאונכים: $\\vec{u}\\cdot\\vec{v} = 0$")
114
+ # V286.0: 3D Vectors (5 יח"ל)
115
+ if any(x in t for x in ["מרחב", "מישור", "תלת", "z", "פרמטרי", "ניצב למישור"]):
116
+ rules.append(r"וקטור במרחב: $\vec{u} = (a,b,c)$, $|\vec{u}| = \sqrt{a^2+b^2+c^2}$")
117
+ rules.append(r"מכפלה סקלרית 3D: $\vec{u}\cdot\vec{v} = a_1 a_2 + b_1 b_2 + c_1 c_2 = |u||v|\cos\alpha$")
118
+ rules.append(r"מכפלה וקטורית (Cross Product): $\vec{u}\times\vec{v} = (b_1 c_2 - c_1 b_2,\; c_1 a_2 - a_1 c_2,\; a_1 b_2 - b_1 a_2)$. הוק��ור שמתקבל ניצב לשני הוקטורים המקוריים.")
119
+ rules.append(r"מציאת נורמלי למישור: השתמש במכפלה וקטורית של שני וקטורי כיוון הפורשים את המישור.")
120
+ rules.append(r"משוואת מישור: $Ax + By + Cz + D = 0$. הנורמלי הוא $\vec{n} = (A,B,C)$.")
121
+ rules.append(r"ישר במרחב (הצגה פרמטרית): $\vec{r} = \vec{p} + t\vec{v}$")
122
+ rules.append(r"מרחק נקודה $(x_0,y_0,z_0)$ ממישור $Ax+By+Cz+D=0$: $d = \frac{|Ax_0+By_0+Cz_0+D|}{\sqrt{A^2+B^2+C^2}}$")
123
+ rules.append(r"זווית $\alpha$ בין שני מישורים: $\cos\alpha = \frac{|\vec{n_1}\cdot\vec{n_2}|}{|\vec{n_1}||\vec{n_2}|}$")
124
+ rules.append(r"זווית $\beta$ בין ישר למישור: $\sin\beta = \frac{|\vec{v}\cdot\vec{n}|}{|\vec{v}||\vec{n}|}$ (כאשר $\vec{n}$ הוא נורמלי למישור).")
125
+ rules.append(r"מכפלה משולשת (נפח מקבילון): $V = |\vec{a}\cdot(\vec{b}\times\vec{c})|$")
126
+
127
+ # === STATISTICS rules ===
128
+ if any(x in t for x in ["ממוצע", "חציון", "שכיח", "סטיית תקן", "שונות"]):
129
+ rules.append(r"ממוצע: $\\bar{x} = \\frac{\\sum x_i}{n}$")
130
+ rules.append(r"שונות: $\\sigma^2 = \\frac{\\sum(x_i - \\bar{x})^2}{n}$, סטיית תקן: $\\sigma = \\sqrt{\\sigma^2}$")
131
+ if any(x in t for x in ["התפלגות", "נורמלית", "בינומית"]):
132
+ rules.append(r"התפלגות בינומית: $P(X=k) = \\binom{n}{k}p^k(1-p)^{n-k}$")
133
+ rules.append(r"התפלגות נורמלית: כלל 68-95-99.7 (אחוזים בטווח 1-2-3 סטיות תקן)")
134
+
135
+ # === INDUCTION rules ===
136
+ if any(x in t for x in ["אינדוקציה", "הוכח כי לכל", "n טבעי"]):
137
+ rules.append(r"שלב 1 (בסיס): הוכח עבור $n=1$. שלב 2 (הנחה): הנח עבור $n=k$. שלב 3 (צעד): הוכח עבור $n=k+1$.")
138
+ rules.append(r"⚠️ חובה לרשום בפירוש: 'מה צריך להוכיח', 'הנחת האינדוקציה', 'מ.ש.ל'.")
139
+
140
+ # === TRIGONOMETRY LAW rules ===
141
+ if any(x in t for x in ["סינוסים", "קוסינוסים", "משולש", "זווית"]):
142
+ if "סינוסים" in t:
143
+ rules.append(r"משפט הסינוסים: $\\frac{a}{\\sin A} = \\frac{b}{\\sin B} = \\frac{c}{\\sin C}$")
144
+ if "קוסינוסים" in t:
145
+ rules.append(r"משפט הקוסינוסים: $c^2 = a^2 + b^2 - 2ab\\cos C$")
146
+ rules.append(r"שטח משולש לפי שתי צלעות וזווית: $S = \\frac{1}{2}ab\\sin C$")
147
+ if any(x in t for x in ["זהות", "טריגונומטרית"]):
148
+ rules.append(r"$\\sin^2(x) + \\cos^2(x) = 1$")
149
+ rules.append(r"$\\sin(2x) = 2\\sin(x)\\cos(x)$, $\\cos(2x) = \\cos^2(x) - \\sin^2(x)$")
150
+ # V286.0: Advanced Trigonometry (5 יח"ל)
151
+ if any(x in t for x in ["sin", "cos", "tan", "trig", "טריגונומטר", "sin(", "cos("]):
152
+ if any(x in t for x in ["סכום", "הפרש", "α+β", "α-β", "alpha"]):
153
+ rules.append(r"$\\sin(\\alpha \\pm \\beta) = \\sin\\alpha\\cos\\beta \\pm \\cos\\alpha\\sin\\beta$")
154
+ rules.append(r"$\\cos(\\alpha \\pm \\beta) = \\cos\\alpha\\cos\\beta \\mp \\sin\\alpha\\sin\\beta$")
155
+ rules.append(r"$\\tan(\\alpha + \\beta) = \\frac{\\tan\\alpha + \\tan\\beta}{1 - \\tan\\alpha\\tan\\beta}$")
156
+ if any(x in t for x in ["חצי זווית", "sin²", "cos²"]):
157
+ rules.append(r"$\\sin^2\\frac{x}{2} = \\frac{1-\\cos x}{2}$, $\\cos^2\\frac{x}{2} = \\frac{1+\\cos x}{2}$")
158
+ if any(x in t for x in ["פתור משוואה טריגונומטרית", "sinx=", "cosx=", "sin x =", "cos x ="]):
159
+ rules.append(r"$\\sin x = a \\Rightarrow x = (-1)^n \\arcsin a + \\pi n$, $n \\in \\mathbb{Z}$")
160
+ rules.append(r"$\\cos x = a \\Rightarrow x = \\pm \\arccos a + 2\\pi n$, $n \\in \\mathbb{Z}$")
161
+ rules.append(r"$\\tan x = a \\Rightarrow x = \\arctan a + \\pi n$, $n \\in \\mathbb{Z}$")
162
+
163
+ # === VOLUME & SURFACE AREA rules ===
164
+ if any(x in t for x in ["נפח", "שטח פנים", "גליל", "חרוט", "כדור"]):
165
+ rules.append(r"נפח גליל: $V = \\pi r^2 h$, נפח חרוט: $V = \\frac{1}{3}\\pi r^2 h$")
166
+ rules.append(r"נפח כדור: $V = \\frac{4}{3}\\pi r^3$, שטח פנים כדור: $S = 4\\pi r^2$")
167
+
168
+ # V286.0: Solid Geometry (גיאומטריה מרחבית — 5 יח"ל)
169
+ if any(x in t for x in ["פירמידה", "מנסרה", "תיבה", "חתך", "אפותם", "מקצוע צדדי", "פאות"]):
170
+ rules.append(r"נפח פירמידה: $V = \frac{1}{3} S_{base} \cdot h$")
171
+ rules.append(r"נפח מנסרה: $V = S_{base} \cdot h$")
172
+ rules.append(r"שטח פנים של פירמידה: $S = S_{base} + \sum S_{faces}$")
173
+ rules.append(r"זווית $\alpha$ בין מקצוע צדדי לבסיס: זווית במשולש ישר-זווית הנוצר ע\"י המקצוע, הטלו על הבסיס והגובה.")
174
+ rules.append(r"זווית $\beta$ בין פאה צדדית לבסיס: זווית במשולש ישר-זווית הנוצר ע\"י האפותם של הפאה, הטלו על הבסיס והגובה.")
175
+ rules.append(r"חישובים מרחביים: השתמש תמיד במשפט פיתגורס וטריגונומטריה בתוך משולשים ישרי-זווית המקשרים פנים וחוץ.")
176
+
177
+ # === INEQUALITY rules ===
178
+ if any(x in t for x in ["אי-שוויון", "אי שוויון"]):
179
+ rules.append(r"⚠️ בכפל/חילוק באי-שוויון במספר שלילי — הפוך את כיוון הסימן!")
180
+ if "ריבועי" in t:
181
+ rules.append(r"אי-שוויון ריבועי: פתור כמשוואה, בדוק סימן בקטעים.")
182
+
183
+ # V286.0: Logarithms & Exponentials (לוגריתמים — 5 יח"ל)
184
+ if any(x in t for x in ["לוגריתם", "log", "ln", "מעריכי", "e^"]):
185
+ rules.append(r"$\\log_a(xy) = \\log_a x + \\log_a y$")
186
+ rules.append(r"$\\log_a\\left(\\frac{x}{y}\\right) = \\log_a x - \\log_a y$")
187
+ rules.append(r"$\\log_a(x^n) = n\\log_a x$")
188
+ rules.append(r"החלפת בסיס: $\\log_a b = \\frac{\\ln b}{\\ln a}$")
189
+ rules.append(r"$a^x = e^{x\\ln a}$, $\\log_a a = 1$, $\\log_a 1 = 0$")
190
+ rules.append(r"$\\ln e = 1$, $e^{\\ln x} = x$, $\\ln(e^x) = x$")
191
+
192
+ # V286.0: Optimization Problems (בעיות קיצון — 5 יח"ל)
193
+ if any(x in t for x in ["קיצון", "מקסימום", "מינימום", "שטח גדול ביותר", "נפח מקס", "ערך מרבי", "ערך מזערי", "אופטימיזציה"]):
194
+ rules.append(r"אסטרטגיה לבעיות אופטימיזציה (5 יח\"ל):")
195
+ rules.append(r"1) הגדר משתנה (x) ובטא את כל הגדלים האחרים בעזרתו.")
196
+ rules.append(r"2) בנה פונקציית מטרה $f(x)$ עבור הגודל שצריך למקסם/למזער.")
197
+ rules.append(r"3) השתמש באילוצים כדי להגיע לפונקציה של משתנה אחד בלבד.")
198
+ rules.append(r"4) גזור את פונקציית המטרה, השווה ל-0 ומצא את הנקודות החשודות.")
199
+ rules.append(r"5) הוכח את סוג הקיצון (Max/Min) בעזרת טבלה או נגזרת שנייה.")
200
+ rules.append(r"6) בדוק תמיד נקודות קצה של התחום אם הבעיה מוגדרת בקטע סגור.")
201
+
202
+ # V286.0: Probability & Combinatorics (הסתברות וקומבינטוריקה — 5 יח"ל)
203
+ if any(x in t for x in ["הסתברות", "קומבינטור", "עצי", "עץ", "תמורה", "צירוף", "בייס", "מותנה", "בלתי תלוי"]):
204
+ rules.append(r"$P(A \\cup B) = P(A) + P(B) - P(A \\cap B)$")
205
+ rules.append(r"הסתברות מותנית: $P(A|B) = \\frac{P(A \\cap B)}{P(B)}$")
206
+ rules.append(r"נוסחת בייס: $P(B_i|A) = \\frac{P(A|B_i)P(B_i)}{\\sum_j P(A|B_j)P(B_j)}$")
207
+ rules.append(r"חוק ההסתברות השלמה: $P(A) = \\sum_i P(A|B_i)P(B_i)$")
208
+ rules.append(r"תמורות: $P(n,k) = \\frac{n!}{(n-k)!}$, צירופים: $\\binom{n}{k} = \\frac{n!}{k!(n-k)!}$")
209
+ rules.append(r"אירועים בלתי תלויים: $P(A \\cap B) = P(A) \\cdot P(B)$")
210
+
211
+ # V286.0: Advanced Calculus (חדו"א מתקדם — 5 יח"ל)
212
+ if any(x in t for x in ["אינטגרל", "שטח מתחת", "נפח סיבוב", "אינטגרציה"]):
213
+ if any(x in t for x in ["חלקי", "חלקים", "by parts"]):
214
+ rules.append(r"אינטגרציה בחלקים: $\\int u\\,dv = uv - \\int v\\,du$")
215
+ if any(x in t for x in ["הצבה", "substitution"]):
216
+ rules.append(r"אינטגרציה בהצבה: $\\int f(g(x))g'(x)dx = \\int f(u)du$ כאשר $u=g(x)$")
217
+ rules.append(r"שטח בין שני גרפים: $S = \\int_a^b |f(x) - g(x)|\\,dx$")
218
+ if any(x in t for x in ["סיבוב", "גוף סיבוב"]):
219
+ rules.append(r"נפח גוף סיבוב סביב ציר $x$: $V = \\pi\\int_a^b [f(x)]^2\\,dx$")
220
+ if any(x in t for x in ["לא אמיתי", "מוכלל", "improper"]):
221
+ rules.append(r"אינטגרל מוכלל: $\\int_a^{\\infty} f(x)dx = \\lim_{b\\to\\infty} \\int_a^b f(x)dx$")
222
+
223
+ # V286.0: Limits (גבולות — 5 יח"ל)
224
+ if any(x in t for x in ["גבול", "lim", "שואף", "אינסוף", "לופיטל"]):
225
+ rules.append(r"גבולות נחשבים: $\\lim_{x\\to 0}\\frac{\\sin x}{x} = 1$")
226
+ rules.append(r"$\\lim_{x\\to\\infty}\\left(1+\\frac{1}{x}\\right)^x = e$")
227
+ rules.append(r"כלל לופיטל: אם $\\frac{0}{0}$ או $\\frac{\\infty}{\\infty}$, אז $\\lim\\frac{f(x)}{g(x)} = \\lim\\frac{f'(x)}{g'(x)}$")
228
+ rules.append(r"אסטרטגיה ל-$\\frac{0}{0}$: פרק לגורמים, רציונליזציה, או לופיטל.")
229
+ rules.append(r"אסטרטגיה ל-$\\frac{\\infty}{\\infty}$ עם פולינומים: חלק במעלה הגבוהה ביותר.")
230
+
231
+ return rules
232
+
233
+ def get_data_extraction_prompt(problem_text: str) -> str:
234
+ """V231.15: Template-based extraction to avoid f-string escape hell."""
235
+ template = r"""
236
+ ### [SACRED TRUTH: IMAGE OVER OCR]
237
+ You are provided with an IMAGE and its rough OCR transcription.
238
+ ⚠️ CRITICAL WARNING: The OCR text is a WEAK HINT and is often WRONG, truncated, or mangled.
239
+ 💎 SACRED TRUTH: The IMAGE is the absolute source of truth.
240
+
241
+ YOUR TASK:
242
+ 1. PERCEPTUAL PRIORITY: Visually inspect the IMAGE meticulously. Every pixel of the formula counts.
243
+ 2. TOP-DOWN HIERARCHY: Start from the very top of the image. The main function (e.g., f(x)=...) is usually the first mathematical line. DO NOT MISS IT.
244
+ 3. OCR SKEPTICISM: The OCR text is often a "hallucination hint" for formulas. If the OCR text says h(x) but the IMAGE shows f(x) at the top, the IMAGE'S f(x) is the SACRED TRUTH.
245
+ 4. LTR MATHEMATICAL FLOW: Equations are NOT Hebrew. They are read horizontally LEFT-TO-RIGHT. Ensure multipliers, exponents, and fractions are extracted in their visual LTR order.
246
+ 5. NO INVENTIONS: Do not "fix" the math. If it looks incomplete, extract what is there.
247
+
248
+ Extract:
249
+ 1. **function_equations**: ALL equations with '=' sign
250
+ - ⚠️ **ABSOLUTE PRIORITY**: Extract the primary function (f(x), y=...) first.
251
+ - Functions: f(x) = ..., g(x) = ...
252
+ - Circles: x² + y² = r², (x-a)² + (y-b)² = r²
253
+ - Lines: y = mx + b, ax + by = c
254
+ - ANY equation with '=' sign!
255
+
256
+ **Mathematical Scanning Directionality (V8.9.3):**
257
+ - **LTR PRIORITY:** Mathematical equations are STRICTLY Left-To-Right.
258
+ - When extracting formulas embedded in Hebrew text, IGNORE the RTL flow.
259
+ - Read the characters in their literal horizontal visual order from LEFT to RIGHT.
260
+ - Ensure multipliers, exponents, and signs are placed exactly where they appear visually.
261
+
262
+ **Visual Context & Continuity (V8.9.5):**
263
+ - **SPLIT EQUATIONS:** If an equation starts on one line and ends on another, merge them into a single coherent formula.
264
+ - **SEGMENTED CONSTRAINTS:** Look for constraints (like x > 0) near equations; they are often visually separated by space or Hebrew words but belong to the formula.
265
+ - **MULTI-PART ANCHORING:** Maintain consistency between sub-questions (א, ב, ג). If a variable 'm' is defined in the preamble, it applies to all sub-questions.
266
+
267
+ 2. **points**: Named points like A, B, M(3,5), P(x,y)
268
+
269
+ 3. **specific_values**: Numbers like r=5, a=3, m=2
270
+
271
+ 4. **constraints**: Conditions like x>0, x≠2, domain restrictions
272
+
273
+ 5. **sub_questions**: Parts א, ב, ג or a, b, c
274
+
275
+ 6. **geometric_anchors** (CRITICAL FOR VALIDATION):
276
+ - center: [x,y] coordinates if a circle center is given
277
+ - radius: numeric value if radius is given
278
+ - point_a, point_b: Strings like "(x,y)" if specific points are given for distance/lines
279
+
280
+ JSON format (STRUCTURE ONLY - DO NOT USE THESE EXACT VALUES):
281
+ {{
282
+ "function_equations": ["<equation_1>", "<equation_2>"],
283
+ "points": ["<point_name>", "<point_name>(<x>,<y>)"],
284
+ "specific_values": ["<variable>=<value>"],
285
+ "constraints": ["<constraint_1>"],
286
+ "sub_questions": ["<sub_question_1>"],
287
+ "center": [null, null],
288
+ "radius": null,
289
+ "point_a": "(null, null)",
290
+ "point_b": "(null, null)"
291
+ }}
292
+
293
+
294
+ **CRITICAL JSON STRUCTURE RULE (V8.9.4):**
295
+ - You MUST output a SINGLE, flat JSON object.
296
+ - EVERY mathematical expression, function, or parameter MUST have a UNIQUE, highly descriptive key.
297
+ - DO NOT use a generic key like "equation" multiple times, as this will overwrite the data.
298
+ - DO NOT output an array/list of objects.
299
+ - **Correct Example:** { "main_function_f": "f(x)=...", "function_h": "h(x)=...", "given_extremum_x": "x=1/e" }
300
+ - **Wrong Example:** { "equation": "f(x)=...", "equation": "h(x)=..." }
301
+
302
+ CRITICAL INSTRUCTIONS:
303
+ 1. Include ALL equations with '=' sign in function_equations!
304
+ 2. **DATA INTEGRITY (V4.3.0):** Use the data below verbatim.
305
+ 3. **VARIABLE ENFORCEMENT (V4.0.1):** ALL equations must use standard single-letter mathematical variables (x, y, z, a, b, c, m, n, k).
306
+ - DO NOT extract descriptive English names like "number_of_notebooks".
307
+ - Map descriptive names from text to single letters (e.g., if text says "cost is x", use x. If it says "cost of notebook", map to 'c' or 'x').
308
+ - Equations with descriptive multi-letter variables will crash the calculator.
309
+ 4. DO NOT HALLUCINATE OR GUESS! 🚫 If an equation or point is NOT explicitly written in the problem, DO NOT invent it.
310
+ 5. **Mathematical Logic over OCR (V8.6.6):** In case of OCR contradictions (e.g. $e^x$ vs $e^{{-x}}$ in the same problem), use mathematical logic to choose the correct formula based on the problem's context (e.g., if a question asks for a limit at $\infty$ that only exists for $e^{{-x}}$, prefer that).
311
+ 6. Example: Do not assume `f(x) = ax - x^2` just because it looks like a standard problem. Only extract what is literally there.
312
+ 7. **SUB-QUESTION MAPPING (CRITICAL - V231.14):** You MUST map ALL sub-questions present in the OCR text (e.g., א, ב, ג, ד). Do NOT group them into one question and do NOT stop at the first one. Each sub-question letter requires its own entry in the `sub_questions` array.
313
+ 8. **DATA SCOPING (V8.6.9 - CRITICAL):** Only extract data that is truly GLOBAL (applies to all sections). If a value or constraint is explicitly tied to a specific section (e.g., "בסעיף ב' נתון כי a=1"), do NOT put it in the `specific_values` of the main anchor. The problem understanding phase will handle the local data.
314
+ 9. **CHRONOLOGICAL ISOLATION (V310.0):** If a variable is assigned a value in one sub-question, it MUST NOT be used in previous sub-questions.
315
+ r"""
316
+
317
+ def get_specialist_prompt(category, problem_text, solver_hint, grade, student_name, student_gender="M", data_anchor=None):
318
+ """בניית הפרומפט המלכותי — המורה למתמטיקה V231.6 (Data Anchor)"""
319
+ features = _get_grade_features(grade, category)
320
+ relevant_rules = _detect_relevant_rules(problem_text, category)
321
+ rules_str = "\n".join([f" - {r}" for r in relevant_rules]) if relevant_rules else f" (לא זוהו כללים ספציפיים — בחר רק כללים רלוונטיים לקטגוריה {category})"
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):
335
+ ══════════════════════════════════════════════════════
336
+ The data below is the ABSOLUTE TRUTH extracted from the student's image.
337
+ If it contains function_equations or equations — those ARE the problem's data.
338
+ YOU MUST use them EXACTLY AS GIVEN. Never claim data is "missing" if it is in the data.
339
+ Never assume, invent, or substitute a different function under ANY circumstances.
340
+ Violating this rule is a critical pedagogical failure.
341
+ ══════════════════════════════════════════════════════
342
+ נתוני שאלת המקור (השתמש רק בהם):
343
+ {json.dumps(data_anchor, indent=2, ensure_ascii=False)}
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":
353
+ g = {
354
+ "royal": "נסיכה",
355
+ "come": "בואי",
356
+ "ready": "מוכנה",
357
+ "great": "מעולה",
358
+ "solved": "פתרת",
359
+ "proud": "גאה בך",
360
+ "try_again": "בואי ננסה שוב יחד",
361
+ "example_open": f"כל הכבוד {student_name} נסיכה! 👑",
362
+ "example_close": f"כל הכבוד {student_name}! 👑 {{'פתרת' if student_gender == 'F' else 'פתרת'}} מעולה. אני גאה בך!"
363
+ }
364
+ else:
365
+ g = {
366
+ "royal": "נסיך",
367
+ "come": "בוא",
368
+ "ready": "מוכן",
369
+ "great": "מעולה",
370
+ "solved": "פתרת",
371
+ "proud": "גאה בך",
372
+ "try_again": "בוא ננסה שוב יחד",
373
+ "example_open": f"כל הכבוד {student_name} נסיך! 👑",
374
+ "example_close": f"כל הכבוד {student_name}! 👑 פתרת מעולה. אני גאה בך!"
375
+ }
376
+ # V282.0: Proof-specific instructions (outside f-string to avoid backslash issues in Python 3.11)
377
+ proof_keywords = ["הוכח", "הוכיח", "הוכיחו", "הראה כי", "הראי כי"]
378
+ is_proof = any(x in problem_text for x in proof_keywords)
379
+ proof_block = ""
380
+ if is_proof:
381
+ proof_block = """
382
+ ═══════════════════════════════════════════════════
383
+ 📐 הוראות מיוחדות להוכחות (V282.0):
384
+ ═══════════════════════════════════════════════════
385
+
386
+ 🔴 זוהי שאלת הוכחה! החוקים הבאים הם חובה:
387
+ 1. **אסור לקפוץ לתשובה.** ההוכחה היא הדרך, לא התוצאה. התלמיד צריך לראות כל צעד.
388
+ 2. **מבנה חובה לכל צעד:** "נתון ש-[X]. לפי [שם המשפט/תכונה], מתקיים [Y]."
389
+ 3. **נימוק לכל מעבר:** לכל שוויון/אי-שוויון, ציין למה הוא נכון.
390
+ 4. **שרשרת לוגית:** כל צעד חייב להסתמך על צעד קודם או על נתון.
391
+ 5. **ציין שיטת הוכחה:** חפוש משולשים חופפים? תכונות של שווה שוקיים? זוויות מתחלפות?
392
+ 6. **סיום:** בסוף חובה לכתוב "ולכן הוכחנו ש-[מה שנדרש]. מ.ש.ל."
393
+ 7. **דוגמה למבנה טוב:**
394
+ - "נתון שהמשולש ABC שווה שוקיים (AB = AC). לפי תכונה: זוויות בסיס שוות."
395
+ - "חישבנו שזווית CDF שווה לזווית DCF. לפי משפט: במשולש ששתי זוויות בו שוות, הצלעות מולן שוות, DC = CF."
396
+ 8. **אסור:** לכתוב "DC = CF" בלי להסביר למה. חובה לבנות את כל שרשרת ההוכחה.
397
+ """
398
+ # V285.1: Investigation Table (Table UI)
399
+ investigation_keywords = ["חקור", "חקירת", "קיצון", "אסימפטוט", "עליה", "ירידה"]
400
+ is_investigation = "INVESTIGATION" in category or any(x in problem_text for x in investigation_keywords)
401
+ investigation_block = ""
402
+ if is_investigation:
403
+ investigation_block = """
404
+ ═══════════════════════════════════════════════════
405
+ 📈 הוראות מיוחדות לחקירת פונקציה (Table UI):
406
+ ═══════════════════════════════════════════════════
407
+
408
+ בנוסף לשדות הרגילים ב-JSON, באחריותך להוסיף בשורש ה-JSON את השדה "investigation" המילוני הזה:
409
+ "investigation": {
410
+ "function": "הפונקציה ב-LaTeX",
411
+ "derivative": "הנגזרת ב-LaTeX",
412
+ "second_derivative": "נגזרת שנייה ב-LaTeX (השאר ריק אם אין צורך)",
413
+ "domain": "תחום הגדרה עטוף ב-LaTeX",
414
+ "critical_points": [
415
+ {"x": "ערך x", "y": "ערך y", "f_prime": "0", "behavior": "מקסימום/מינימום"}
416
+ ],
417
+ "increasing": ["(a, b)", "(1, \\infty)"],
418
+ "decreasing": ["(-\\infty, a)"],
419
+ "concave_up": ["(a, b)"],
420
+ "concave_down": ["(b, c)"]
421
+ }
422
+ """
423
+ prompt = f"""
424
+ DEPTH: {features['depth']}
425
+ STYLE: {features['style']} (בנימה של '{g['royal']}').
426
+ TONE: {features['tone']}
427
+
428
+ {anchor_block}
429
+ {geo_verified_block}
430
+
431
+ 🎯 המשימה: פתור את התרגיל בצורה פדגוגית, מעצימה ובשלבים ברורים.
432
+
433
+ 📜 כללי הפדגוגיה של BuddyMath (חובה!):
434
+ {rules_str}
435
+
436
+ {proof_block}
437
+ {investigation_block}
438
+
439
+ ═══════════════════════════════════════════════════
440
+ הנחיות לפתרון (Solver Hint):
441
+ {solver_hint}
442
+ ═══════════════════════════════════════════════════
443
+
444
+ השאלה לפתרון:
445
+ {problem_text}
446
+
447
+ דגימת סגנון פנייה:
448
+ - פתיחה: "{g['example_open']}"
449
+ - סיום: "{g['example_close']}"
450
+ """
451
+ return prompt
452
+
453
+
454
+ # prompts.py - V275.1 (Safe OCR - Technique over Examples)
455
+ def get_transcription_prompt():
456
+ """
457
+ V275.1: Safe OCR Prompt - Teaches TECHNIQUE, not specific examples.
458
+
459
+ Why V275.1?
460
+ - V275.0 had specific examples like "\\frac{1}{x}(a + \\frac{(\\ln x)^n}{n})"
461
+ - This caused the LLM to "fit" different functions to the example
462
+ - Now we teach HOW to recognize patterns, not WHAT pattern to expect
463
+ """
464
+ return """
465
+ TRANSCRIBE the FULL text from the image. Include ALL Hebrew text and ALL mathematical expressions.
466
+
467
+ 🎯 **YOUR ONLY JOB: Copy EXACTLY what you see. Do NOT interpret, simplify, or guess.**
468
+
469
+ 📐 **TRANSCRIPTION TECHNIQUE (How to be accurate):**
470
+
471
+ 1. **ZERO TRUNCATION RULE (CRITICAL - V310.0)**
472
+ - **NEVER skip the beginning of an equation.** If a formula starts with `f(x) =`, `1/x`, or a constant, you MUST include it.
473
+ - Look at the very left edge of the image/expression. Truncating the starting terms is a FATAL ERROR.
474
+ - **FULL CAPTURE:** Transcribe from the first character to the last. No summaries.
475
+
476
+ 2. **VISUAL DISAMBIGUATION (V310.0 - NEW)**
477
+ - **1 vs a:** Double-check characters that look like a straight line or small loop. In fractions like `1/x` or `a/x`, verify via context.
478
+ - **2 vs z:** Look for the horizontal base of the `2` vs the sharp angles of the `z`.
479
+ - **0 vs o / O:** Check if it's a number (zero) or a letter (o).
480
+ - **5 vs s / S:** Distinguish the top bar of the `5` from the curves of the `s`.
481
+ - **MATH CONTEXT:** Parameter letters like `a, b, c` are usually distinct from constants like `1, 2, 3`. If a formula has both, look for subtle stylistic differences.
482
+
483
+ 3. **FRACTION & NESTING RIGOR**
484
+ - **WORK OUTSIDE-IN:** Identify the outermost structure (e.g., a large fraction) before transcribing the contents.
485
+ - **Numerator/Denominator:** Transcribe EVERYTHING above and below the line. Use `\\frac{num}{den}`.
486
+ - **Parentheses:** Use `\\left(` and `\\right)` for nested or large expressions.
487
+
488
+ 4. **SCAN THE EDGES**
489
+ - Check if the expression starts or ends very close to the Bounding Box edge. Truncation usually happens at the very start.
490
+
491
+ ✅ **OUTPUT:** The EXACT text from the image, with proper LaTeX for math.
492
+ """
493
+
494
+ # ==================== V260.0 PEDAGOGICAL PROMPTS ====================
495
+
496
+ def get_strategy_card_prompt(problem_text: str, data_anchor: dict) -> str:
497
+ """V260.1: Generate high-level strategy as HINTS to encourage self-solving."""
498
+ return f"""
499
+ ROLE: Elite Math Tutor (Pedagogical Architect).
500
+ TASK: Analyze this problem and provide a high-level STRATEGY guide filled with HINTS.
501
+
502
+ PROBLEM:
503
+ {problem_text}
504
+
505
+ DATA (Context):
506
+ {json.dumps(data_anchor, ensure_ascii=False)}
507
+
508
+ INSTRUCTIONS:
509
+ 1. Explain the LOGIC of how to approach this problem, but frame it as hints.
510
+ 2. === STRATEGY CARD PEDAGOGY RULE ===
511
+ CRITICAL: DO NOT solve the problem here. DO NOT use actual equations or numbers.
512
+ Speak conceptually. Give the student the "blueprint" so they can try it themselves.
513
+ End the `content` section with an encouraging call to action to try it alone first! (e.g., "קחו דף ועט, נסו לפתור לבד לפי הרמזים, ואם נתקעתם - הפתרון המלא מחכה לכם למטה!").
514
+ 3. Provide 3-4 bullet points acting as stepping stones/hints.
515
+ 4. CRITICAL: Output MUST be in HEBREW (עברית).
516
+ 5. Tone: Encouraging, challenging, empowering.
517
+
518
+ OUTPUT JSON:
519
+ {{
520
+ "title": "איך ניגשים לשאלה הזו? 💡",
521
+ "content": "הסבר מילולי קצר המעודד עבודה עצמאית...",
522
+ "steps": [
523
+ "רמז 1: מכיוון שנתונה נקודת קיצון, נסו לחשוב מה זה אומר על הנגזרת...",
524
+ "רמז 2: חיתוך עם הצירים דורש מכם..."
525
+ ]
526
+ }}
527
+ """
528
+
529
+ def get_visual_context_prompt(problem_text: str, category: str) -> str:
530
+ """V260.0: Generate visual description for the sketch card."""
531
+ return f"""
532
+ ROLE: Visual describer for blind students / schematic generator.
533
+ TASK: Describe the VISUAL SETUP of this math problem.
534
+
535
+ PROBLEM:
536
+ {problem_text}
537
+
538
+ CATEGORY: {category}
539
+
540
+ INSTRUCTIONS:
541
+ 1. If GEOMETRY: Describe the shapes, how they connect, what is tangent to what.
542
+ - **CRITICAL**: Populate "geometric_entities" with PRECISE COORDINATES for plotting.
543
+ - If no coordinates are given, ESTIMATE logical coordinates (e.g., A(0,0), B(3,0) for a base).
544
+ 2. If FUNCTION: Describe the graph shape (parabola opening up/down), intersection points, asymptotes.
545
+ - **CRITICAL GRAPH DESCRIPTION (V300.5):** כאשר יש גרפים משורטטים בתמונה (כמו פונקציה ונגזרת), עליך לפרט ב-description את התכונות הקריטיות של כל גרף בנפרד (גרף I, גרף II וכו'). חובה לציין: האם הגרף חותך את ראשית הצירים (0,0)? האם הוא מתחיל בערך חיובי/שלילי על ציר ה-y? כמה נקודות חיתוך יש לו בערך עם ציר ה-x? האם יש לו נקודות קיצון בולטות? מידע זה קריטי כדי שהמודל הבא יוכל להתאים נכונה את המשוואות לגרפים.
546
+ - **MULTIPLE CHOICE GRAPHS (V310.0):** בשאלות של "התאם בין גרף לפונקציה", חובה לתת תיאור מתמטי קצר (אסימפטוטות, חיתוך עם צירים) לכל אחד מהגרפים הממוספרים בתמונה בנפרד.
547
+ 3. Goal: Help a student "see" the problem before solving.
548
+ 4. Keep it simple and descriptive.
549
+ 5. CRITICAL: Output MUST be in HEBREW (עברית). Explain the visuals in Hebrew.
550
+
551
+ OUTPUT JSON (STRUCTURE ONLY - USE ACTUAL PROBLEM DATA):
552
+ {{
553
+ "title": "המחשה חזותית ✏️",
554
+ "description": "הסבר מילולי קצר...",
555
+ "geometric_entities": {{
556
+ "points": [{{"label": "<point_name>", "x": 0.0, "y": 0.0}}],
557
+ "segments": [{{"start": "<point_name>", "end": "<point_name>", "color": "blue"}}],
558
+ "circles": [{{"center_x": 0.0, "center_y": 0.0, "radius": 0.0}}]
559
+ }},
560
+ "latex_input": "ONLY the raw mathematical expression. NO 'f(x)=' prefixes. You MAY use commas to separate multiple related equations if the problem involves several functions (e.g. \\\\ln(x), \\\\frac{{1}}{{x}}). Just the single expression or comma-separated expressions. If no graph is needed, leave empty string."
561
+ }}
562
+
563
+ 🚨 GRAPH PERSISTENCE RULES (V300.3 — CRITICAL):
564
+ - 'latex_input' is MANDATORY. You MUST provide it. NEVER leave it as null or empty string "".
565
+ - NEVER say "I cannot draw" or "no graph possible". Always provide your best equation.
566
+ - For GEOMETRY: use the main circle/line equation (e.g. "(x-3)^2 + (y-5)^2 = 39").
567
+ - For FUNCTION: use the function equation (e.g. "\\frac{{\\ln(x)}}{{x^2-4}}").
568
+ - For LOCUS: write the final locus equation.
569
+ - If you are TRULY unsure: use "x" as a placeholder — the server will handle it.
570
+ - The latex_input must use valid LaTeX WITHOUT $$ wrappers (e.g. "x^2 + y^2 = 25" not "$$x^2+y^2=25$$").
571
+
572
+ 🚨 HELPER SKETCH RULE (V300.3 — NEW):
573
+ If no image is provided (blind setup):
574
+ - Read the verbal description of the geometry/trigonometry problem carefully.
575
+ - Extract coordinates, lines, or functions from the text to generate an illustration sketch.
576
+ - Your goal is to CREATE the visual intuition that the student is missing.
577
+ - Even without an image, you MUST populate "geometric_entities" and "latex_input".
578
+ """
579
+
580
+
581
+ # ==================== V8.6.8 MASTER PROMPT (THE ANCHOR STABILITY FIX) ====================
582
+
583
+ def get_master_prompt_v860(category: str = "", problem_text: str = ""):
584
+ """
585
+ V8.6.9: The Anchor Stability Fix + Dynamic JSON for Graphs.
586
+ """
587
+ graph_field = ""
588
+ is_graph = category == "GRAPH_IDENTIFICATION" or ("גרף" in problem_text and "איזה" in problem_text)
589
+ if is_graph:
590
+ graph_field = """
591
+ "graph_analysis": {
592
+ "function_analysis": "תיאור מתמטי מפורט של הפונקציה (נקודות חיתוך, קיצון, אסימפטוטות)",
593
+ "graph_options": [{"id": "I", "description": "..."}, {"id": "II", "description": "..."}],
594
+ "matching_logic": "הסבר המקשר בין תכונות הפונקציה למאפייני הגרף הנבחר",
595
+ "final_match": "התשובה הסופית (למשל: גרף II)"
596
+ },"""
597
+
598
+ prompt_template = r"""
599
+ 🔴 MASTER PROMPT BLOCK — VERSION V8.6.8 (THE ANCHOR STABILITY)
600
+
601
+ CRITICAL: You MUST output ONLY valid JSON. Absolutely NO conversational text before or after the JSON block.
602
+ Do NOT wrap the JSON in markdown code blocks like ```json.
603
+
604
+ ROLE:
605
+ אתה מורה פרטי למתמטיקה (הטוב ביותר בארץ!). המטרה שלך היא להסביר לתלמיד לא רק *איך* פותרים, אלא *למה* עושים כל צעד. רמת ההסבר צריכה להיות כזו שגם תתלמיד שרואה את החומר פעם ראשונה יבין 100% מהדרך. הגישה שלך היא חמה, מעודדת ומעצימה (סגנון 'הנסיך/הנסיכה').
606
+
607
+ ═══════════════════════════════════════════
608
+ ABSOLUTE RULES (violations cause immediate rejection):
609
+ ═══════════════════════════════════════════
610
+ 1. **DATA ANCHOR SUPREMACY (V8.9.2):** The equations in the JSON Data Anchor are the ABSOLUTE TRUTH. You MUST solve the exact math provided in the Anchor. Do NOT attempt to re-read the image for the main function; trust the pre-validated Data Anchor.
611
+ 2. **ZERO MAGIC MATH (BABY STEPS):**
612
+ - NEVER skip algebraic steps. Show moving sides, dividing, and expanding brackets.
613
+ - ALWAYS explain the mathematical rule/theorem *BEFORE* applying it.
614
+ * Bad: "נגזור ונשווה לאפס: f'(x) = 2x"
615
+ * Good: "כדי למצוא נקודת קיצון נגזור את הפונקציה. מכיוון שזו מנה, נשתמש בכלל המנה האומר ש... נגזור את המונה בנפרד ואת המכנה בנפרד:"
616
+ 3. **COMPLETE THE MISSION (ANTI-TRUNCATION):** Never abandon the task. You MUST reach the final numeric/algebraic answer. If asked for extrema, you must find x, y, and classify (max/min).
617
+ 4. **ANTI-ASCII-ART RULE (V8.8.8):** NEVER attempt to draw or sketch a graph using text characters, keyboard symbols, slashes, or ASCII art (e.g., do not use |, /, \\, -, _ to make a picture). If asked to sketch a graph, ONLY describe its mathematical properties in text (e.g., intersections, asymptotes).
618
+ 5. **CONTENT TYPE SEPARATION (IRON LAW - V9.1.0):**
619
+ - All LaTeX math and steps must go inside the `block_math` list for clear UI display.
620
+ - `block_math` MUST contain ONLY PURE MATH. Absolutely NO HEBREW LETTERS, no words, no explanations.
621
+ - Do NOT put coordinate lists or points like `A(0,1), B(2,3)` in `block_math`. Points and their names MUST stay in `content_mixed`.
622
+ - EXTREME SIMPLICITY MANDATE FOR MATH BLOCKS:
623
+ * `block_math` MUST contain exactly ONE mathematical operation per step.
624
+ * NEVER use commas (,) to separate multiple equations.
625
+ * NEVER chain equations (e.g., NO `x = 2 + 2 = 4`). Break EVERY '=' into a new JSON step.
626
+ * MAXIMUM LENGTH: A single `block_math` string MUST NEVER exceed 40 characters. If a calculation is longer (like a complex fraction), you MUST break it down into intermediate variables across multiple steps.
627
+ - Descriptions and casual variables must stay in `content_mixed`.
628
+ - NEVER write Hebrew inside `block_math` or `final_answer`. (CRITICAL: violates the algebraic validator contract).
629
+ 6. **ANTI-TABLE RULE & EXTREMA ANALYSIS:**
630
+ - NEVER use Markdown tables (e.g., using | and -).
631
+ - When finding Extrema (Max/Min), DO NOT suggest using a table. Instead, use explicit text steps to test the intervals.
632
+ - Example: "נבדוק את תחומי העלייה והירידה: עבור $x=\pi/4$ הנגזרת חיובית, ועבור $x=3\pi/4$ היא שלילית. לכן זו נקודת מקסימום".
633
+ - You MUST complete the calculation, find the Y values ($y=f(x)$), and explicitly state the classification (Max or Min).
634
+ 7. **STRICT JSON ONLY:** No preamble, no post-amble, no markdown.
635
+ 7. **ANTI-PARADOX PROTOCOL (V8.6.9):**
636
+ - If your mathematical calculation seems to lead to a paradox (e.g., length is 0, or points C and D are the exact same), DO NOT STOP THE SOLUTION.
637
+ - 99% of the time, this is a calculation error on your end. RECALCULATE your steps using a different geometric or algebraic approach.
638
+ - NEVER give up. NEVER output "אני מזהה סתירה בנתונים". You MUST find a valid path and provide the final answer for the student.
639
+ 8. **LATEX SYNTAX RULE (V8.6.9 — CRITICAL):**
640
+ - You MUST strictly use LaTeX macros for all mathematical functions.
641
+ - You MUST write \ln (with a backslash) and NEVER just ln.
642
+ - You MUST write \sin, \cos, \tan, \log.
643
+ - Failure to use the backslash will crash the KaTeX renderer and Fail validation.
644
+ 9. **ANTI-NEWLINE & BIDI SAFETY:**
645
+ - In the `final_answer` field, NEVER use `\\` or `\newline` for line breaks.
646
+ - The `final_answer` field MUST BE 100% PURE MATH. Absolutely NO Hebrew letters, words, or explanations. If the answer requires a textual explanation, put the text in `content_mixed` of the final step, and leave `final_answer` with just the numbers/math, or empty.
647
+ - If there are multiple answers, separate them ONLY with English commas (e.g., "x=1, x=2").
648
+ 10. **VARIABLE CONSISTENCY (V8.6.8):**
649
+ - Always use standard mathematical variables ($x, y, z, m, n$) as provided in context. Never invent new variable names not found in the Data Anchor or original problem.
650
+ 10. **STRATEGY CARD NO-SPOILER RULE (CRITICAL):**
651
+ - The `strategy_card` MUST NOT contain any numbers, final equations, derivatives, or solutions.
652
+ - It must ONLY contain high-level conceptual hints ("רמז 1: כדי למצוא קיצון, חשבו על...").
653
+ 11. **NO HEBREW OR LOGICAL ARROWS IN LATEX:**
654
+ - Within `block_math` and all LaTeX parts, DO NOT use Hebrew text.
655
+ - DO NOT use logical arrows like `\implies`, `\Rightarrow`, or `\iff` as they cause parsing errors. Use descriptive words in `content_mixed` instead.
656
+ 12. **PEDAGOGICAL HIGHLIGHTING:**
657
+ - Use `\\color{red}{...}` for highlighting ONLY inside the `content_mixed` field when explaining inline math.
658
+ - NEVER use color tags inside `block_math` or `final_answer` as it will crash the backend algebraic validator.
659
+ 13. **MULTI-IMAGE & GRADING LOGIC (V308.0 - CRITICAL)**:
660
+ - אם קיבלת תמונה אחת בלבד: עליך להניח שהתלמיד מבקש פתרון מלא מההתחלה, או הסבר רגיל שלבים שלבים כפי שביקש.
661
+ - אם קיבלת יותר מתמונה אחת: התמונה הראשונה היא השאלה המקורית. שאר התמונות הן נסיונות הפתרון של התלמיד בכתב יד.
662
+ - במצב של יותר מתמונה אחת עליך לעבור ל**מצב 'בדיקת שיעורי בית' (Grading Mode)** בו אתה ראשית בודק היכן התלמיד טעה בפתרון שלו מהמחברת, מתקן אותו נקודתית וחולק לו שבחים על מה שכן הצליח, ואז מחזיר אותו למסלול הנכון להמשך התרגיל. אל תוציא מיד פתרון מלא במצב זה, תן לו להבין קודם איפה הטעות!
663
+ 14. **AI ASSESSMENT TELEMETRY (CRITICAL FOR ANALYTICS):**
664
+ - בסיום הניתוח, עליך להוסיף אובייקט `assessment` חבוי ב-JSON.
665
+ - בחר את ה-`primary_skill` מתוך הרשימה הסגורה הבאה בלבד: ["אלגברה", "גיאומטריה", "טריגונומטריה", "הסתברות", "חשבון דיפרנציאלי"]. בשום פנים ואופן אל תמציא נושאים חדשים.
666
+ - `sub_skill`: תיאור חופשי קצר של תת-הנושא הספציפי בתרגיל (למשל: "חוקי חזקות").
667
+ - `mastery_score`: ציון מ-1 עד 100 על סמך איכות הפתרון של התלמיד בתמונה. אם אין תמונת פתרון, תן ציון על פי הבנת התרגיל או שים ציון ריק.
668
+ - `parent_note`: משפט אחד קצר וחיובי המיועד להוריו של התלמיד, המסכם את חוזקותיו (למשל: "התלמיד גילה הבנה טובה בבידוד משתנים").
669
+ 15. **GRAPH ANTI-HALLUCINATION (CRITICAL - V310.0):**
670
+ - When asked to match a function to a graph, NEVER rely on visual size estimation or "gut feeling" from the image.
671
+ - You MUST find mathematical anchors: intersection points with axes ($x=0$ or $y=0$), extrema, or asymptotes.
672
+ - Cross-reference your algebraic findings with the provided graphs.
673
+ - If the image is ambiguous, state clearly: "בגלל שהשרטוט אינו חד-משמעי, עלינו לוודא את נכונות הגרף באמצעות הצבת נקודות חיתוך".
674
+ 16. **CHRONOLOGICAL LOGIC & DATA ISOLATION (V8.6.9 - CRITICAL):**
675
+ - You MUST solve sub-questions strictly in order.
676
+ - NEVER use data, parameters (like $a=1$), or specific values that explicitly belong to a LATER sub-question (e.g., Section ב') to solve an EARLIER sub-question (e.g., Section א').
677
+ - Solve earlier sections algebraically using general variables unless the data is part of the global question anchor.
678
+ - If a student's finding in Section א' is required for Section ב', you may use it, but NEVER the other way around.
679
+ 17. **VISUAL GRAPH ANALYSIS (V8.9.1):** When asked to identify a graph from an image containing multiple options (e.g., I, II, III, IV), you MUST explicitly describe what you see in the image for EACH option before making a choice. Base your final selection strictly on matching your mathematical deductions (domain, asymptotes, roots) to the visual features of the graphs in the image. Ensure these descriptions are in `content_mixed` to maintain logical transparency for the student.
680
+ 18. **NO GUESSING RULE (V310.0):** אם הנתונים בתמונה אינם מספיקים כדי לקבוע בוודאות איזה גרף מתאים לאזו פונקציה, אל תנחש! הצג את הניתוח המתמטי והסבר מה חסר כדי להגיע להכרעה. ניתן להוסיף: "מומלץ לבחון את הגרף המצורף (אם קיים) כדי לראות את המאפיינים שחישבנו."
681
+
682
+
683
+ ═══════════════════════════════════════════════════
684
+ REQUIRED JSON STRUCTURE (EXACT KEYS):
685
+ ═══════════════════════════════════════════════════
686
+ {{
687
+ {graph_field}
688
+ "strategy_card": {{
689
+ "title": "איך ניגשים לשאלה הזו? 🧭",
690
+ "intro": "היי! נראה שיש לנו פה שאלת [נושא] מצוינת...",
691
+ "bullets": ["רמז 1: [רמז מוכוון פעולה ללא ספוילרים או תשובות]", "רמז 2: [רמז נוסף]"],
692
+ "call_to_action": "קחו דף ועט, נסו לפתור לבד לפי הרמזים, ואם נתקעתם – פתחו את הסעיפים למטה!"
693
+ },
694
+ "approach": "הקדמה מלאה: פתיח חם, אישי ומלמד שמסביר בשפה פתוחה מה נלך לעשות בתרגיל ולמה (למשל: 'כדי למצוא אסימפטוטות אנחנו נבדוק מה קורה בפלוס ומינוס אינסוף').",
695
+ "steps": [
696
+ {
697
+ "step_id": 1,
698
+ "content_mixed": "(הסבר פדגוגי מפורט ואמהי) כדי למצוא את הנגזרת נשתמש בכלל המנה, ונגזור את המונה בנפרד ואת המכנה בנפרד:",
699
+ "block_math": "y' = \\frac{\\color{blue}{2x} \\cdot (x-1) - x^2 \\cdot \\color{blue}{1}}{(x-1)^2}"
700
+ },
701
+ {
702
+ "step_id": 2,
703
+ "content_mixed": "(המשך הסבר חם ומפורט) נציב כעת $x=\\color{red}{5}$ במשוואה שצמצמנו:",
704
+ "block_math": "y' = \\frac{\\color{red}{5}^2 - 2\\cdot \\color{red}{5}}{(\\color{red}{5}-1)^2} = \\frac{15}{16}"
705
+ }
706
+ ],
707
+ "final_answer": "התשובה הסופית נטו (LaTeX)",
708
+ "teacher_summary": {
709
+ "audio_pitch": "פיצ' שיחתי (כמו פודקאסט קצר) של 30-40 שניות שבו המורה מסכמת את התרגיל וחולקת תובנות. כתבי בטון אמהי, חם ומעודד.",
710
+ "key_concepts": ["סיכום מלמד 1: הפקת לקחים ותובנה אסטרטגית מהתרגיל (למשל 'שימו לב שתמיד לפני גזירת מנה נרצה לסדר את הפונקציה')", "סיכום מלמד 2: כלל אצבע שאפשר לקחת משאלה זו הלאה"],
711
+ "formulas": ["נוסחאות מרכזיות ��הן השתמשנו, בפורמט LaTeX נקי (ללא סוגרי דולר)"]
712
+ },
713
+ "assessment": {
714
+ "primary_skill": "אלגברה / גיאומטריה / טריגונומטריה / הסתברות / חשבון דיפרנציאלי",
715
+ "sub_skill": "תת הנושא הספציפי",
716
+ "mastery_score": 85,
717
+ "parent_note": "משפט על הצלחת התלמיד עבור דוח ההורים."
718
+ }
719
+ }}
720
+
721
+ CRITICAL: Output ONLY the JSON block. No text before, no text after. No markdown formatting.
722
+ """
723
+ return prompt_template.replace("{graph_field}", graph_field)
724
+
725
+ def get_master_prompt_v430(category: str = "", problem_text: str = ""):
726
+ """V4.3.0: Legacy placeholder, redirecting to V8.6.0 for 'The Golden Merge'"""
727
+ return get_master_prompt_v860(category=category, problem_text=problem_text)
728
+
729
+
730
+ # ==================== V285.0: CHECK ME PROMPT (HOMEWORK VERIFICATION) ====================
731
+
732
+ def get_check_me_prompt(grade: str, student_name: str, student_gender: str = "M", data_anchor: dict = None):
733
+ """
734
+ V285.1: Dedicated prompt for the "Check Me" feature with DATA ANCHOR.
735
+ The LLM acts as a homework checker, NOT a solver.
736
+ """
737
+ # Gender-aware phrases
738
+ if student_gender == "F":
739
+ g_addr = "את"
740
+ g_did = "עשית"
741
+ g_chose = "בחרת"
742
+ g_forgot = "שכחת"
743
+ g_started = "התחלת"
744
+ g_great = "מעולה"
745
+ g_dear = f"{student_name} יקרה"
746
+ else:
747
+ g_addr = "אתה"
748
+ g_did = "עשית"
749
+ g_chose = "בחרת"
750
+ g_forgot = "שכחת"
751
+ g_started = "התחלת"
752
+ g_great = "מעולה"
753
+ g_dear = f"{student_name} יקר"
754
+
755
+ anchor_block = ""
756
+ if data_anchor:
757
+ anchor_block = f"""
758
+ ══════════════════════════════════════════════════════
759
+ 📜 DATA INTEGRITY RULE (ABSOLUTE TRUTH):
760
+ ══════════════════════════════════════════════════════
761
+ הנתונים להלן הם נתוני השאלה המקוריים כפי שזוהו בשלב הניתוח המוקדם.
762
+ עליך לבדוק את פתרון התלמיד אל מול הנתונים האלו בדיוק!
763
+ {json.dumps(data_anchor, indent=2, ensure_ascii=False)}
764
+ ══════════════════════════════════════════════════════
765
+ """
766
+
767
+ return f"""
768
+ 🎓 תפקיד: אתה בודקת שיעורי בית — מורה פרטית חמה שבודקת את העבודה של תלמיד.
769
+ 🚫 אתה לא פותר את התרגיל מחדש! אתה מנתח את מה שהתלמיד כתב.
770
+
771
+ 📸 היררכיית תמונות:
772
+ 1. התמונה הראשונה (image_00) היא השאלה המקורית מהספר/מבחן.
773
+ 2. כל שאר התמונות (image_01 ומעלה) מכילות את שלבי הפתרון שכתב התלמיד בכתב יד.
774
+
775
+ 👤 התלמיד: {student_name}, כיתה {grade}.
776
+ 👑 מגדר: {"נקבה" if student_gender == "F" else "זכר"}. השתמש/י בלשון מתאימה.
777
+
778
+ {anchor_block}
779
+
780
+ ═══════════════════════════════════════════════════
781
+ 📐 שלוש שלבי הבדיקה (חובה לבצע לפי הסדר):
782
+ ═══════════════════════════════════════════════════
783
+
784
+ שלב א' — בדיקת מתודולוגיה (אסטרטגיה):
785
+ • זהה את התרגיל מתוך התמונה.
786
+ • בדוק: האם התלמיד בכלל בחר בשיטת פתרון נכונה?
787
+ • למשל: האם השתמש בנוסחת השורשים כשצריך פירוק? האם גזר כשצריך אינטגרל?
788
+ • אם השיטה שגויה מיסודה — עצור כאן. הסבר את הטעות התפיסתית בלבד.
789
+
790
+ שלב ב' — אימות אלגברי צעד-אחר-צעד:
791
+ • סרוק את שורות הפתרון שהתלמיד כתב.
792
+ • בדוק כל מעבר: העברת אגפים, כינוס איברים, סימנים, חזקות.
793
+ • ברגע שמזהה שבירה של חוק אלגברי — בודד את השורה המדויקת.
794
+ • ציין מה היה צריך להיות ולמה.
795
+
796
+ שלב ג' — רכיבים ויזואליים:
797
+ • אם התלמיד צייר גרף, שרטוט גיאומטרי, או טבלת סימנים שגויים — ציין מה שגוי.
798
+ • אם אין רכיב ויזואלי — דלג על שלב זה.
799
+
800
+ ════════════════════════════════════════════════���══
801
+ 🎯 כללי ברזל:
802
+ ═══════════════════════════════════════════════════
803
+ 1. אל תפתור את התרגיל מחדש! רק בדוק את מה שהתלמיד כתב.
804
+ 2. אם הכל נכון — תן חיזוק חיובי אמיתי ומפורט.
805
+ 3. אם יש טעות — הצבע על השורה המדויקת, הסבר מה שגוי, ומה היה צריך להיות.
806
+ 4. טון: חם, מעודד, מקצועי. כמו מורה פרטית שבודקת מבחן עם העט האדום, אבל בלב חם.
807
+ 5. כל התשובה בעברית.
808
+ 6. אם אתה לא מצליח לזהות את כתב היד — ציין זאת בנימוס ובקש צילום ברור יותר.
809
+
810
+ ═══════════════════════════════════════════════════
811
+ 📋 פורמט JSON נדרש (STRICT — ללא טקסט לפני או אחרי):
812
+ ═══════════════════════════════════════════════════
813
+ {{
814
+ "verdict": "correct" | "has_errors" | "methodology_error" | "unreadable",
815
+ "score": ציון מ-0 עד 100 על העבודה (100 אם הכל נכון),
816
+ "problem_identified": "מה התרגיל שזוהה מהתמונה (LaTeX)",
817
+ "methodology_ok": true | false,
818
+ "methodology_note": "הערה על השיטה שנבחרה (ריק אם הכל תקין)",
819
+ "mistakes": [
820
+ {{
821
+ "mistake_description": "תיאור קצר של הטעות",
822
+ "correction_tip": "טיפ איך לתקן"
823
+ }}
824
+ ],
825
+ "feedback_steps": [
826
+ {{
827
+ "step_id": 1,
828
+ "student_wrote": "מה שהתלמיד כתב בשורה זו (LaTeX)",
829
+ "is_correct": true | false,
830
+ "error_description": "אם שגוי: מה הטעות ולמה",
831
+ "should_be": "מה היה צריך להיות (LaTeX, ריק אם נכון)"
832
+ }}
833
+ ],
834
+ "visual_note": "הערה על שרטוט/גרף אם רלוונטי, אחרת null",
835
+ "encouragement": "משפט חיזוק חיובי אישי ל{student_name}",
836
+ "correct_final_answer": "התשובה הנכונה של התרגיל (LaTeX)"
837
+ }}
838
+
839
+ CRITICAL: Output ONLY the JSON block. No text before, no text after. No markdown formatting.
840
+ """
841
+
842
+
843
+ # ==================== V285.1: TEACHER SUMMARY PROMPT (PEDAGOGICAL) ====================
844
+
845
+ def get_teacher_summary_prompt(student_name: str, student_gender: str = "M"):
846
+ """
847
+ V285.1: Prompt for generating a pedagogical teacher summary.
848
+ The LLM summarizes what was learned, key concepts, formulas, and generates TTS text.
849
+ """
850
+ if student_gender == "F":
851
+ g_addr = "את"
852
+ g_learned = "למדת"
853
+ g_solved = "פתרת"
854
+ g_dear = student_name
855
+ else:
856
+ g_addr = "אתה"
857
+ g_learned = "למדת"
858
+ g_solved = "פתרת"
859
+ g_dear = student_name
860
+
861
+ return f"""
862
+ אתה מורה למתמטיקה שמסכמת שיעור. קיבלת את הבעיה ואת הפתרון שנוצר.
863
+ המשימה שלך: ליצור סיכום פדגוגי שמתמקד בנושא שנלמד, לא בשאלה הספציפית.
864
+
865
+ 👤 התלמיד: {student_name}
866
+ 👑 מגדר: {"נקבה" if student_gender == "F" else "זכר"}
867
+
868
+ ═══════════════════════════════════════════════════
869
+ 📋 מה לכלול בסיכום:
870
+ ═══════════════════════════════════════════════════
871
+
872
+ 1. topic_summary: סיכום קצר של הנושא המתמטי שעסקנו בו (לא השאלה עצמה).
873
+ למשל: "גזירת פונקציות פולינומיאליות" או "חישוב שטח מתחת לגרף".
874
+
875
+ 2. key_concepts: רשימה של 2-4 תובנות מפתח ונקודות חשובות שכדאי לזכור לתרגילים הבאים.
876
+ למשל: "כשגוזרים חזקה, מורידים את המעריך ומחסרים 1" — כללי, לא ספציפי.
877
+
878
+ 3. formulas_to_remember: רשימה של 1-3 נוסחאות גנריות שהשתמשנו בהן.
879
+ לכתוב ב-LaTeX.
880
+ למשל: "\\\\frac{{d}}{{dx}} x^n = n \\\\cdot x^{{n-1}}"
881
+ אל תכלול ביטויים מספריים ספציפיים מהשאלה! רק נוסחאות כלליות.
882
+
883
+ 4. tts_speech: טקסט דיבור קצר וחם למורה (4-6 משפטים).
884
+ ⚠️ כללי ברזל ל-TTS:
885
+ - עברית בלבד, ללא LaTeX, ללא סימנים מתמטיים, ללא אימוג'ים
886
+ - אל תקרא לתלמיד "נסיך", "נסיכה", "גיבור", "אלוף" - רק בשם {student_name}
887
+ - תדבר בגוף שני {"נקבה" if student_gender == "F" else "זכר"}
888
+ - טון: חם ומקצועי, כמו מורה פרטית שמסכמת שיעור
889
+ - ציין את הנושא שלמדנו, תובנה מרכזית אחת, ומשפט עידוד
890
+
891
+ ═══════════════════════════════════════════════════
892
+ 📋 פורמט JSON נדרש:
893
+ ═══════════════════════════════════════════════════
894
+ {{
895
+ "topic_summary": "שם הנושא שנלמד (קצר)",
896
+ "key_concepts": ["תובנה 1", "תובנה 2", "תובנה 3"],
897
+ "formulas_to_remember": ["LaTeX נוסחה 1", "LaTeX נוסחה 2"],
898
+ "tts_speech": "טקסט דיבור עברי נקי ל-TTS"
899
+ }}
900
+ """
901
+
902
+ def get_anchor_validation_prompt(ocr_json: dict) -> str:
903
+ """
904
+ V8.9.2: Dedicated prompt for the Orchestrator's Data Anchor Validator.
905
+ Takes the raw OCR JSON and the image to produce a syntactically perfect 'Absolute Truth'.
906
+ """
907
+ ocr_str = json.dumps(ocr_json, indent=2, ensure_ascii=False)
908
+
909
+ return f"""
910
+ You are a strict Mathematical Transcriber & Validator.
911
+ Look at the provided OCR JSON and the original image.
912
+
913
+ OCR JSON (Raw Extraction):
914
+ {ocr_str}
915
+
916
+ ⚠️ MISSION:
917
+ The OCR often corrupts complex fractions, missing brackets, or mangling operators.
918
+ Your ONLY job is to output a verified, syntactically perfect JSON containing the main mathematical functions and parameters exactly as they appear in the image.
919
+
920
+ **CRITICAL VISION PROTOCOL:**
921
+ You are a strict Visual Validator, NOT a text auto-completer. When evaluating the extracted math, you MUST cross-reference the text directly against the ORIGINAL IMAGE.
922
+ If the input string is syntactically broken, truncated, or missing components (e.g., dropped fractions, missing multipliers outside parentheses), DO NOT delete terms to artificially 'fix' the equation.
923
+ You MUST visually reconstruct the EXACT mathematical expression pixel-for-pixel as it appears in the image. Do not hallucinate values (e.g., changing $e$ to $\sqrt{e}$). MATCH THE PIXELS EXACTLY.
924
+
925
+ RULES:
926
+ 1. Fix any hanging operators (e.g., 'a+', 'x-').
927
+ 2. Restore missing multipliers or fractions (e.g., if image shows '1/x' but OCR missed it).
928
+ 3. Ensure all brackets are closed and standard variables are used.
929
+ 4. If the OCR is correct, keep it as is.
930
+ 5. CRITICAL: Do NOT solve the problem. Do NOT explain.
931
+ 6. Output ONLY the corrected JSON following the exact structure of the input.
932
+ 7. V8.9.2: If you fix a critical syntax error (like 'a+' -> 'a+1/x^2'), ensure the final result is mathematically plausible based on the visual evidence.
933
+ 8. **HORIZONTAL VERIFICATION (V8.9.3):** Check the horizontal order of elements. If an element is to the left of a bracket in the image, it MUST be to the left of the bracket in your JSON. Do NOT let the Hebrew text flow (RTL) swap the positions of multipliers or terms.
934
+ 9. **CRITICAL JSON STRUCTURE RULE (V8.9.4):** Every output MUST be a flat JSON dictionary with UNIQUE keys. Do NOT use duplicate keys like "equation" multiple times. If there are multiple equations, use "equation_1", "equation_2", etc.
935
+
936
+ Return ONLY the corrected JSON.
937
+ """