dotandru commited on
Commit
9d29c62
·
0 Parent(s):

Fix: Clean production deployment with sse-starlette

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.env.example ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ GOOGLE_API_KEY=your-gemini-api-key-here
2
+
3
+ # OCR Mode: development = Stitch & Strip engine (new, single-pass)
4
+ # production = Triple-Pass legacy (safe, proven)
5
+ OCR_STRIP_MODE=development
.gitignore ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ .venv/
6
+ .venv_fix/
7
+ venv/
8
+ env/
9
+ ENV/
10
+
11
+ # OS
12
+ .DS_Store
13
+ Thumbs.db
14
+ desktop.ini
15
+
16
+ # Logs
17
+ *.log
18
+ server.log
19
+
20
+ # Credentials
21
+ .env
22
+ serviceAccountKey_dev.json
23
+ .google-credentials.json
24
+
25
+ # Backups
26
+ *.bak
27
+
28
+ # Models & Large Binaries (Unused)
29
+ phonikud-1.0.int8.onnx
30
+
31
+ # Temporary / Cache
32
+ .pytest_cache/
33
+ test_results/
34
+ temp/
35
+ tmp/
36
+ output_locus.txt
37
+
38
+ # Media (Ignore test recordings)
39
+ *.wav
40
+ !welcome_speech_fixed.wav
Dockerfile ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dockerfile - V8.0 FIXED (Uvicorn + SymPy Support)
2
+ FROM python:3.11-slim
3
+
4
+ WORKDIR /app
5
+
6
+ # התקנת תלויות מערכת (פונטים לגרפים)
7
+ RUN apt-get update && apt-get install -y \
8
+ libgl1 \
9
+ libglib2.0-0 \
10
+ fonts-dejavu-core \
11
+ fontconfig \
12
+ && rm -rf /var/lib/apt/lists/* \
13
+ && fc-cache -f -v
14
+
15
+ # התקנת ספריות פייתון
16
+ COPY requirements.txt .
17
+ RUN pip install --no-cache-dir -r requirements.txt
18
+
19
+ # העתקת קבצי האפליקציה
20
+ COPY . .
21
+
22
+ # יצירת משתמש לא-root (דרישת אבטחה)
23
+ RUN useradd -m -u 1000 user
24
+ USER user
25
+ ENV PATH="/home/user/.local/bin:$PATH"
26
+
27
+ # חשיפת פורט 7860
28
+ EXPOSE 7860
29
+
30
+ # --- התיקון הקריטי: הרצה עם uvicorn ---
31
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: BuddyMath
3
+ emoji: 🧮
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ # BuddyMath Server V3.1
11
+
12
+ מורה פרטי AI למתמטיקה לתלמידים ישראליים 🇮🇱
13
+
14
+ ## API Endpoints
15
+
16
+ | Endpoint | Method | Description |
17
+ |----------|--------|-------------|
18
+ | `/` | GET | Health check |
19
+ | `/health` | GET | Server status |
20
+ | `/solve_stream` | POST | פתרון בעיה (SSE) |
21
+
22
+ ## Modes
23
+
24
+ - `solve` - פתור לי
25
+ - `teach_me` - למד אותי
26
+ - `check` - בדוק אותי
27
+
28
+ ## Parameters
29
+
30
+ ```
31
+ image: File (required)
32
+ grade: string (required)
33
+ mode: string (default: "solve")
34
+ student_name: string (default: "תלמיד/ה")
35
+ student_gender: string (default: "male")
36
+ user_note: string (optional)
37
+ ```
all_users.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "test_user_123": {
3
+ "last_seen": "2026-02-15T12:49:04.304849",
4
+ "id": "test_user_123"
5
+ },
6
+ "\u05d3\u05d5\u05ea\u05df": {
7
+ "last_seen": "2026-03-08T15:18:08.252094",
8
+ "id": "\u05d3\u05d5\u05ea\u05df"
9
+ },
10
+ "stress_user_1": {
11
+ "last_seen": "2026-02-27T12:03:10.171108",
12
+ "id": "stress_user_1"
13
+ },
14
+ "stress_user_2": {
15
+ "last_seen": "2026-02-27T12:03:10.221285",
16
+ "id": "stress_user_2"
17
+ },
18
+ "stress_user_3": {
19
+ "last_seen": "2026-02-27T12:03:10.263698",
20
+ "id": "stress_user_3"
21
+ },
22
+ "stress_user_4": {
23
+ "last_seen": "2026-02-27T12:03:10.332969",
24
+ "id": "stress_user_4"
25
+ },
26
+ "stress_user_0": {
27
+ "last_seen": "2026-02-27T12:03:10.382946",
28
+ "id": "stress_user_0"
29
+ },
30
+ "diagnostic": {
31
+ "last_seen": "2026-02-28T11:19:56.770735",
32
+ "id": "diagnostic"
33
+ }
34
+ }
audio_generator.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # audio_generator.py - V273.0 (Google Cloud TTS - High Quality Hebrew)
2
+ import asyncio
3
+ import base64
4
+ import os
5
+ import tempfile
6
+ import logging
7
+
8
+ # Configure Logging
9
+ logger = logging.getLogger(__name__)
10
+
11
+ # ═══════════════════════════════════════════════════════════════
12
+ # 🎙️ Google Cloud TTS Configuration
13
+ # ═══════════════════════════════════════════════════════════════
14
+ #
15
+ # קולות עבריים זמינים:
16
+ # - he-IL-Wavenet-A (נקבה, איכות גבוהה) ⭐ מומלץ
17
+ # - he-IL-Wavenet-B (זכר, איכות גבוהה)
18
+ # - he-IL-Standard-A (נקבה, איכות רגילה)
19
+ # - he-IL-Standard-B (זכר, איכות רגילה)
20
+ #
21
+ # Free Tier: 1 מיליון תווים/חודש (WaveNet: 1M, Standard: 4M)
22
+ # ═══════════════════════════════════════════════════════════════
23
+
24
+ GOOGLE_VOICE_NAME = "he-IL-Wavenet-A" # Female, high quality
25
+ GOOGLE_LANGUAGE_CODE = "he-IL"
26
+ SPEAKING_RATE = 0.95 # מעט יותר איטי לבהירות
27
+ PITCH = 1.0 # גובה קול רגיל
28
+
29
+ # Fallback to edge-tts if Google Cloud not configured
30
+ USE_EDGE_TTS_FALLBACK = True
31
+ EDGE_TTS_VOICE = "he-IL-HilaNeural"
32
+
33
+ from firebase_manager import firebase_manager # V261.17
34
+
35
+
36
+ def _is_google_cloud_configured() -> bool:
37
+ """בדיקה אם Google Cloud מוגדר"""
38
+ # Option 1: Environment variable
39
+ if os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"):
40
+ return True
41
+ # Option 2: Check for credentials file in common locations
42
+ common_paths = [
43
+ "/app/google-credentials.json",
44
+ "./google-credentials.json",
45
+ os.path.expanduser("~/.config/gcloud/application_default_credentials.json")
46
+ ]
47
+ for path in common_paths:
48
+ if os.path.exists(path):
49
+ os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = path
50
+ return True
51
+ return False
52
+
53
+
54
+ async def _generate_with_google_cloud(text: str, output_path: str) -> bool:
55
+ """
56
+ יצירת אודיו עם Google Cloud TTS
57
+ מחזיר True אם הצליח, False אם נכשל
58
+ """
59
+ try:
60
+ from google.cloud import texttospeech
61
+
62
+ # Create client
63
+ client = texttospeech.TextToSpeechClient()
64
+
65
+ # Build the voice request
66
+ voice = texttospeech.VoiceSelectionParams(
67
+ language_code=GOOGLE_LANGUAGE_CODE,
68
+ name=GOOGLE_VOICE_NAME,
69
+ )
70
+
71
+ # Select the audio format
72
+ audio_config = texttospeech.AudioConfig(
73
+ audio_encoding=texttospeech.AudioEncoding.MP3,
74
+ speaking_rate=SPEAKING_RATE,
75
+ pitch=PITCH,
76
+ )
77
+
78
+ # Build the synthesis input
79
+ synthesis_input = texttospeech.SynthesisInput(text=text)
80
+
81
+ # Perform the text-to-speech request
82
+ logger.info(f"🎙️ Google Cloud TTS: Generating audio for {len(text)} chars...")
83
+
84
+ # Run in thread pool to not block async
85
+ loop = asyncio.get_running_loop()
86
+ response = await loop.run_in_executor(
87
+ None,
88
+ lambda: client.synthesize_speech(
89
+ input=synthesis_input,
90
+ voice=voice,
91
+ audio_config=audio_config
92
+ )
93
+ )
94
+
95
+ # Write the audio content to file
96
+ with open(output_path, "wb") as out:
97
+ out.write(response.audio_content)
98
+
99
+ logger.info(f"✅ Google Cloud TTS: Audio saved to {output_path}")
100
+ return True
101
+
102
+ except ImportError:
103
+ logger.warning("⚠️ google-cloud-texttospeech not installed. Run: pip install google-cloud-texttospeech")
104
+ return False
105
+ except Exception as e:
106
+ logger.error(f"❌ Google Cloud TTS failed: {e}")
107
+ return False
108
+
109
+
110
+ async def _generate_with_edge_tts(text: str, output_path: str) -> bool:
111
+ """
112
+ יצירת אודיו עם edge-tts (Fallback)
113
+ """
114
+ try:
115
+ import edge_tts
116
+
117
+ logger.info(f"🎙️ Edge TTS (Fallback): Generating audio...")
118
+ communicate = edge_tts.Communicate(text, EDGE_TTS_VOICE)
119
+ await communicate.save(output_path)
120
+
121
+ logger.info(f"✅ Edge TTS: Audio saved to {output_path}")
122
+ return True
123
+
124
+ except Exception as e:
125
+ logger.error(f"❌ Edge TTS failed: {e}")
126
+ return False
127
+
128
+
129
+ async def generate_teacher_audio(text: str, output_path: str = None) -> str:
130
+ """
131
+ V273.0: יצירת אודיו עם Google Cloud TTS (איכות גבוהה)
132
+
133
+ מנסה קודם Google Cloud TTS, אם לא מוגדר/נכשל → edge-tts fallback
134
+
135
+ Returns:
136
+ - Public URL (if Firebase upload success)
137
+ - Base64 string (fallback)
138
+ - None (if all failed)
139
+ """
140
+ try:
141
+ if not text:
142
+ return None
143
+
144
+ # Clean text for TTS (remove emojis and special chars that cause issues)
145
+ clean_text = _clean_text_for_tts(text)
146
+
147
+ if not clean_text:
148
+ return None
149
+
150
+ logger.info(f"🎙️ TTS Request: {clean_text[:50]}...")
151
+
152
+ # Determine output path
153
+ if output_path:
154
+ os.makedirs(os.path.dirname(output_path), exist_ok=True)
155
+ final_path = output_path
156
+ else:
157
+ timestamp = int(asyncio.get_event_loop().time() * 1000)
158
+ final_path = os.path.join(tempfile.gettempdir(), f"audio_{timestamp}.mp3")
159
+
160
+ # Try Google Cloud TTS first
161
+ success = False
162
+ if _is_google_cloud_configured():
163
+ success = await _generate_with_google_cloud(clean_text, final_path)
164
+ else:
165
+ logger.info("ℹ️ Google Cloud not configured, using Edge TTS")
166
+
167
+ # Fallback to edge-tts
168
+ if not success and USE_EDGE_TTS_FALLBACK:
169
+ success = await _generate_with_edge_tts(clean_text, final_path)
170
+
171
+ if not success:
172
+ logger.error("❌ All TTS methods failed")
173
+ return None
174
+
175
+ # Try Firebase Upload
176
+ try:
177
+ blob_name = f"audio/{os.path.basename(final_path)}"
178
+ loop = asyncio.get_running_loop()
179
+
180
+ public_url = await loop.run_in_executor(
181
+ None,
182
+ lambda: firebase_manager.upload_file(final_path, blob_name)
183
+ )
184
+
185
+ if public_url:
186
+ logger.info(f"☁️ Firebase URL: {public_url}")
187
+ # Clean up local file
188
+ if not output_path:
189
+ os.remove(final_path)
190
+ return public_url
191
+ except Exception as fb_err:
192
+ logger.warning(f"⚠️ Firebase upload failed ({fb_err}). Using Base64.")
193
+
194
+ # Fallback: Return Base64
195
+ with open(final_path, "rb") as audio_file:
196
+ audio_bytes = audio_file.read()
197
+ audio_base64 = base64.b64encode(audio_bytes).decode('utf-8')
198
+
199
+ # Clean up temp file
200
+ if not output_path:
201
+ os.remove(final_path)
202
+
203
+ return audio_base64
204
+
205
+ except Exception as e:
206
+ logger.error(f"❌ TTS Generation Failed: {e}")
207
+ return None
208
+
209
+
210
+ def _clean_text_for_tts(text: str) -> str:
211
+ """
212
+ ניקוי טקסט לפני TTS - הסרת אימוג'ים וסימנים בעייתיים
213
+ """
214
+ import re
215
+
216
+ if not text:
217
+ return ""
218
+
219
+ # Remove emojis
220
+ emoji_pattern = re.compile("["
221
+ u"\U0001F600-\U0001F64F" # emoticons
222
+ u"\U0001F300-\U0001F5FF" # symbols & pictographs
223
+ u"\U0001F680-\U0001F6FF" # transport & map symbols
224
+ u"\U0001F1E0-\U0001F1FF" # flags
225
+ u"\U00002702-\U000027B0"
226
+ u"\U000024C2-\U0001F251"
227
+ "]+", flags=re.UNICODE)
228
+
229
+ clean = emoji_pattern.sub('', text)
230
+
231
+ # Remove multiple spaces
232
+ clean = re.sub(r'\s+', ' ', clean)
233
+
234
+ # Remove LaTeX remnants that might have slipped through
235
+ clean = clean.replace('$', '').replace('\\', '')
236
+
237
+ return clean.strip()
238
+
239
+
240
+ # ═══════════════════════════════════════════════════════════════
241
+ # 🧪 Testing
242
+ # ═══════════════════════════════════════════════════════════════
243
+
244
+ if __name__ == "__main__":
245
+ async def main():
246
+ text = """
247
+ איזה יופי של תרגיל! היינו צריכים למצוא את נקודות הקיצון של הפונקציה.
248
+ השתמשנו בנגזרת ראשונה כדי למצוא איפה השיפוע מתאפס.
249
+ הטריק לזכור - נגזרת אפס תמיד מסמנת נקודת קיצון אפשרית.
250
+ כל הכבוד על ההתמדה!
251
+ """
252
+
253
+ print(f"🎙️ Testing TTS...")
254
+ print(f"📝 Text length: {len(text)} chars")
255
+ print(f"☁️ Google Cloud configured: {_is_google_cloud_configured()}")
256
+
257
+ result = await generate_teacher_audio(text)
258
+
259
+ if result:
260
+ if result.startswith("http"):
261
+ print(f"✅ Got URL: {result}")
262
+ else:
263
+ print(f"✅ Got Base64: {len(result)} chars")
264
+ else:
265
+ print("❌ TTS failed")
266
+
267
+ asyncio.run(main())
config.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # config.py - V1.0 (Central Config)
2
+ import os
3
+ from dotenv import load_dotenv
4
+
5
+ # Load environment variables from .env file
6
+ load_dotenv()
7
+
8
+ # Determine Environment
9
+ # Defaults to PROD if not specified, for safety
10
+ ENV = os.getenv("ENV", "production").lower()
11
+ IS_PRODUCTION = ENV == "production"
12
+
13
+ # Firebase Configuration
14
+ if IS_PRODUCTION:
15
+ # PROD: bussymath
16
+ FIREBASE_CREDENTIALS_PATH = os.path.join(os.path.dirname(__file__), "serviceAccountKey.json")
17
+ STORAGE_BUCKET = "bussymath.firebasestorage.app"
18
+ PROJECT_ID = "bussymath"
19
+ else:
20
+ # DEV: buddy-math-dev
21
+ # The dev bucket buddy-math-dev is returning 404, so we must use the prod bucket AND prod key
22
+ FIREBASE_CREDENTIALS_PATH = os.path.join(os.path.dirname(__file__), "serviceAccountKey.json")
23
+
24
+ STORAGE_BUCKET = "bussymath.firebasestorage.app"
25
+ PROJECT_ID = "bussymath"
26
+
27
+ # Server Configuration
28
+ HOST = "0.0.0.0"
29
+ PORT = 8000 if not IS_PRODUCTION else 7860 # HF Spaces/Cloud Run often use 7860 or PORT env
30
+
31
+ # V3.1.2: Adaptive Failure Mode - Confidence Thresholds
32
+ # V3.1.3: Recalibrated (Hard Floor 0.55)
33
+ # V4.2.11: Lowered for DEV to unblock geometry diagnostics
34
+ CONFIDENCE_THRESHOLD_HIGH = 0.75
35
+ CONFIDENCE_THRESHOLD_MEDIUM = 0.55 if IS_PRODUCTION else 0.01
36
+
37
+ print(f"[CONFIG] Loading {ENV.upper()} configuration.")
38
+ print(f"[CONFIG] Project: {PROJECT_ID}")
39
+ print(f"[CONFIG] Bucket: {STORAGE_BUCKET}")
cost_tracker.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ PRICING = { "input": 0.10, "output": 0.40 }
2
+
3
+ class CostTracker:
4
+ def __init__(self):
5
+ self.input_tokens = 0
6
+ self.output_tokens = 0
7
+
8
+ def add(self, usage_metadata):
9
+ if usage_metadata:
10
+ self.input_tokens += getattr(usage_metadata, 'prompt_token_count', 0)
11
+ self.output_tokens += getattr(usage_metadata, 'candidates_token_count', 0)
12
+
13
+ def add_manual(self, inp, out):
14
+ self.input_tokens += inp
15
+ self.output_tokens += out
16
+
17
+ def get_cost(self):
18
+ return (self.input_tokens / 1e6 * PRICING["input"]) + (self.output_tokens / 1e6 * PRICING["output"])
19
+
20
+ def get_summary(self): # ✅ הפונקציה שהייתה חסרה
21
+ return {
22
+ "input_tokens": self.input_tokens,
23
+ "output_tokens": self.output_tokens,
24
+ "cost_usd": self.get_cost()
25
+ }
26
+
27
+ def print_report(self):
28
+ print(f"💰 COST: ${self.get_cost():.6f}")
29
+
30
+ # Static Logger
31
+ import os
32
+ import json
33
+ import time
34
+ import tempfile
35
+
36
+ # V260.9: Smarter logging path
37
+ def get_log_file_path():
38
+ base_dir = os.path.join(os.getcwd(), "logs")
39
+ # Try creating/accessing local logs dir
40
+ try:
41
+ if not os.path.exists(base_dir):
42
+ os.makedirs(base_dir)
43
+ # Test write permission
44
+ test_file = os.path.join(base_dir, ".test_write")
45
+ with open(test_file, "w") as f: f.write("ok")
46
+ os.remove(test_file)
47
+ return os.path.join(base_dir, "usage.jsonl")
48
+ except (PermissionError, OSError):
49
+ # Fallback to temp
50
+ return os.path.join(tempfile.gettempdir(), "buddy_math_usage.jsonl")
51
+
52
+ LOG_FILE = get_log_file_path()
53
+ LOG_DIR = os.path.dirname(LOG_FILE)
54
+
55
+ def log_api_usage(usage_metadata, source="unknown"):
56
+ """Log API usage to proper location"""
57
+ if not usage_metadata:
58
+ return
59
+
60
+ try:
61
+ # LOG_DIR check is now redundant but safe
62
+ if not os.path.exists(LOG_DIR):
63
+ os.makedirs(LOG_DIR, exist_ok=True)
64
+
65
+ entry = {
66
+ "timestamp": time.time(),
67
+ "source": source,
68
+ "input_tokens": getattr(usage_metadata, 'prompt_token_count', 0),
69
+ "output_tokens": getattr(usage_metadata, 'candidates_token_count', 0),
70
+ "total_tokens": getattr(usage_metadata, 'total_token_count', 0)
71
+ }
72
+
73
+ with open(LOG_FILE, "a", encoding="utf-8") as f:
74
+ f.write(json.dumps(entry) + "\n")
75
+
76
+ # Also print to stdout for immediate visibility in console logs
77
+ print(f"💰 [USAGE] {source}: In={entry['input_tokens']}, Out={entry['output_tokens']}")
78
+
79
+ except Exception as e:
80
+ # Fallback to pure stdout if file logging completely fails
81
+ print(f"💰 [USAGE-STDOUT only] {source}: In={getattr(usage_metadata, 'prompt_token_count', 0)}, Out={getattr(usage_metadata, 'candidates_token_count', 0)}")
82
+ print(f"⚠️ [USAGE LOG ERROR] Failed to log to file {LOG_FILE}: {e}")
curriculum_engine.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # curriculum_engine.py - V4.0 (Curriculum Oracle)
2
+ # Defines pedagogical boundaries according to the Israeli Ministry of Education (Mafmar).
3
+
4
+ import logging
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ CURRICULUM_RULES = {
9
+ # grade: { blacklist: [operators], whitelist: [operators], descriptions: {operator: hebrew_name} }
10
+ 7: {
11
+ "blacklist": ["SYSTEM_OF_EQUATIONS", "QUADRATIC_EQUATION", "DERIVATIVE", "INTEGRAL", "TRIGONOMETRY"],
12
+ "whitelist": ["LINEAR_EQUATION", "FRACTIONS", "EXPONENTS_BASIC", "PERCENTAGES"],
13
+ "descriptions": {
14
+ "SYSTEM_OF_EQUATIONS": "מערכת משוואות בשני נעלמים",
15
+ "QUADRATIC_EQUATION": "משוואה ריבועית",
16
+ "DERIVATIVE": "נגזרות",
17
+ "TRIGONOMETRY": "טריגונומטריה"
18
+ }
19
+ },
20
+ 8: {
21
+ "blacklist": ["QUADRATIC_EQUATION", "DERIVATIVE", "INTEGRAL", "TRIG_Trig_ADVANCED"],
22
+ "whitelist": ["LINEAR_EQUATION", "SYSTEM_OF_EQUATIONS", "BASIC_GEOMETRY", "PYTHAGOREAN_THEOREM"],
23
+ "descriptions": {
24
+ "QUADRATIC_EQUATION": "משוואה ריבועית",
25
+ "DERIVATIVE": "נגזרות"
26
+ }
27
+ },
28
+ 9: {
29
+ "blacklist": ["DERIVATIVE", "INTEGRAL", "LOGARITHM"],
30
+ "whitelist": ["QUADRATIC_EQUATION", "SYSTEM_OF_EQUATIONS", "SIMILAR_TRIANGLES", "CIRCLE_BASIC"],
31
+ "descriptions": {
32
+ "DERIVATIVE": "נגזרות",
33
+ "LOGARITHM": "לוגריתמים"
34
+ }
35
+ },
36
+ 10: {
37
+ "blacklist": ["INTEGRAL_ADVANCED", "VECTORS"],
38
+ "whitelist": ["DERIVATIVE_POLYNOMIAL", "TRIGONOMETRY_BASIC", "CIRCLE_EQUATION_BASIC"],
39
+ "descriptions": {
40
+ "INTEGRAL_ADVANCED": "אינטגרלים מורכבים",
41
+ "VECTORS": "ווקטורים"
42
+ }
43
+ },
44
+ # V286.0: Grade 11 — 4 יח"ל focus
45
+ 11: {
46
+ "blacklist": ["VECTORS_3D", "ADVANCED_INTEGRATION", "SOLID_GEOMETRY_ADVANCED"],
47
+ "whitelist": ["DERIVATIVE", "TRIGONOMETRY", "LOGARITHM", "SEQUENCES", "COMPLEX_NUMBERS_BASIC",
48
+ "FUNCTION_ANALYSIS", "PROBABILITY_BASIC", "CIRCLE_EQUATION"],
49
+ "descriptions": {
50
+ "VECTORS_3D": "וקטורים במרחב",
51
+ "ADVANCED_INTEGRATION": "אינטגרציה מתקדמת"
52
+ }
53
+ },
54
+ # V286.0: Grade 12 / 5 יח"ל — full access
55
+ 12: {
56
+ "blacklist": [],
57
+ "whitelist": ["DERIVATIVE", "INTEGRAL", "INTEGRAL_ADVANCED", "TRIGONOMETRY",
58
+ "TRIGONOMETRY_ADVANCED", "LOGARITHM", "SEQUENCES", "COMPLEX_NUMBERS",
59
+ "COMPLEX_ANALYSIS", "VECTORS", "VECTORS_3D", "FUNCTION_ANALYSIS",
60
+ "PROBABILITY", "COMBINATORICS", "SOLID_GEOMETRY", "LIMITS",
61
+ "OPTIMIZATION", "INDUCTION", "GEOMETRY_ANALYTIC", "CIRCLE_EQUATION"],
62
+ "descriptions": {}
63
+ }
64
+ }
65
+
66
+ def get_allowed_math_operators(grade_str: str, level: str = "4", topic: str = "GENERAL") -> dict:
67
+ """
68
+ Returns the pedagogical boundary rules for a specific student profile.
69
+
70
+ Args:
71
+ grade_str: Student grade (e.g. "7", "י", "י\"א")
72
+ level: Units level for high school (3, 4, 5)
73
+ topic: Current mathematical topic
74
+
75
+ Returns:
76
+ dict: { "allowed": [], "forbidden": [], "reason_map": {} }
77
+ """
78
+ # 1. Normalize grade
79
+ from topic_taxonomy import _extract_grade_number
80
+ grade_num = _extract_grade_number(grade_str)
81
+
82
+ print(f"🎓 [CURRICULUM] Fetching rules for Grade {grade_num}, Level {level} units")
83
+
84
+ rules = CURRICULUM_RULES.get(grade_num, {
85
+ "blacklist": [],
86
+ "whitelist": ["GENERAL"],
87
+ "descriptions": {}
88
+ })
89
+
90
+ # 2. Add Level-specific tweaks (Epic V4.1+)
91
+ if grade_num >= 10:
92
+ if level == "3":
93
+ rules["blacklist"].extend(["COMPLEX_ANALYSIS", "IMPLICIT_DERIVATIVE"])
94
+ elif level == "5":
95
+ rules["whitelist"].extend(["COMPLEX_ANALYSIS", "ADVANCED_CALCULUS"])
96
+
97
+ return {
98
+ "allowed": rules.get("whitelist", []),
99
+ "forbidden": rules.get("blacklist", []),
100
+ "reason_map": rules.get("descriptions", {})
101
+ }
102
+
103
+ def is_operator_allowed(operator: str, grade_str: str, level: str = "4") -> bool:
104
+ """Helper to check a single operator."""
105
+ rules = get_allowed_math_operators(grade_str, level)
106
+ if operator in rules["forbidden"]:
107
+ return False
108
+ return True
109
+
110
+ if __name__ == "__main__":
111
+ # Test
112
+ print(get_allowed_math_operators("7"))
113
+ print(is_operator_allowed("DERIVATIVE", "7"))
114
+ print(is_operator_allowed("SYSTEM_OF_EQUATIONS", "7"))
115
+ print(is_operator_allowed("SYSTEM_OF_EQUATIONS", "8"))
curriculum_israel.py ADDED
@@ -0,0 +1,579 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # curriculum_israel.py - תוכנית הלימודים במתמטיקה לפי משרד החינוך
2
+ # ====================================================================
3
+ # מותאם לכל כיתה ורמה (ז'-י"ב) לפי תוכנית הלימודים הישראלית
4
+ # כל התוכן בעברית!
5
+
6
+ """
7
+ מבנה מערכת החינוך בישראל - מתמטיקה:
8
+ ======================================
9
+ חטיבת ביניים (ז'-ט'): רמה א' מצטיינים, רמה א' רגילה, רמה ב'
10
+ חטיבה עליונה (י'-י"ב): 3 יח"ל, 4 יח"ל, 5 יח"ל
11
+ """
12
+
13
+ # =============================================================================
14
+ # פרופילי כיתות - מה לומדים בכל כיתה
15
+ # =============================================================================
16
+
17
+ GRADE_PROFILES = {
18
+
19
+ # ═══════════════════════════════════════════════════════════════════════
20
+ # כיתה ז'
21
+ # ═══════════════════════════════════════════════════════════════════════
22
+ "ז": {
23
+ "name": "כיתה ז'",
24
+ "age_range": "12-13",
25
+ "levels": {
26
+ "א_מצטיינים": {
27
+ "name": "רמה א' מצטיינים",
28
+ "topics": [
29
+ "מספרים שליליים וחיוביים",
30
+ "חזקות ושורשים",
31
+ "ביטויים אלגבריים",
32
+ "משוואות ממעלה ראשונה",
33
+ "יחס ופרופורציה",
34
+ "אחוזים",
35
+ "גאומטריה: זוויות, משולשים, מרובעים",
36
+ "שטח והיקף",
37
+ "נפח ושטח פנים",
38
+ "סטטיסטיקה תיאורית",
39
+ ],
40
+ "expected_level": "מתקדם - שאלות מאתגרות, חשיבה מתמטית",
41
+ "explanation_style": "הסברים מעמיקים עם הרחבות",
42
+ },
43
+ "א_רגילה": {
44
+ "name": "רמה א' רגילה",
45
+ "topics": [
46
+ "מספרים שליליים וחיוביים",
47
+ "חזקות בסיסיות",
48
+ "ביטויים אלגבריים פשוטים",
49
+ "משוואות ממעלה ראשונה",
50
+ "יחס ופרופורציה",
51
+ "אחוזים",
52
+ "גאומטריה בסיסית",
53
+ "שטח והיקף",
54
+ ],
55
+ "expected_level": "סטנדרטי - בניית בסיס חזק",
56
+ "explanation_style": "הסברים ברורים עם דוגמאות רבות",
57
+ },
58
+ "ב": {
59
+ "name": "רמה ב'",
60
+ "topics": [
61
+ "מספרים שלמים",
62
+ "פעולות חשבון בסיסיות",
63
+ "שברים ומספרים עשרוניים",
64
+ "אחוזים פשוטים",
65
+ "גאומטריה בסיסית",
66
+ "מדידה",
67
+ ],
68
+ "expected_level": "בסיסי - חיזוק יסודות",
69
+ "explanation_style": "הסברים פשוטים מאוד, צעד אחר צעד, עם הרבה עידוד",
70
+ },
71
+ },
72
+ },
73
+
74
+ # ═══════════════════════════════════════════════════════════════════════
75
+ # כיתה ח'
76
+ # ═══════════════════════════════════════════════════════════════════════
77
+ "ח": {
78
+ "name": "כיתה ח'",
79
+ "age_range": "13-14",
80
+ "levels": {
81
+ "א_מצטיינים": {
82
+ "name": "רמה א' מצטיינים",
83
+ "topics": [
84
+ "פונקציה לינארית",
85
+ "מערכת משוואות",
86
+ "אי-שוויונות",
87
+ "משולשים: חפיפה ודמיון",
88
+ "משפט פיתגורס",
89
+ "מעגל",
90
+ "הסתברות",
91
+ "סטטיסטיקה",
92
+ ],
93
+ "expected_level": "מתקדם - הכנה לתיכון ברמה גבוהה",
94
+ "explanation_style": "הסברים מעמיקים עם קישורים בין נושאים",
95
+ },
96
+ "א_רגילה": {
97
+ "name": "רמה א' רגילה",
98
+ "topics": [
99
+ "פונקציה לינארית",
100
+ "מערכת משוואות פשוטה",
101
+ "אי-שוויונות פשוטים",
102
+ "משולשים וחפיפה",
103
+ "משפט פיתגורס",
104
+ "הסתברות בסיסית",
105
+ ],
106
+ "expected_level": "סטנדרטי",
107
+ "explanation_style": "הסברים ברורים ומסודרים",
108
+ },
109
+ "ב": {
110
+ "name": "רמה ב'",
111
+ "topics": [
112
+ "משוואות פשוטות",
113
+ "גרפים בסיסיים",
114
+ "גאומטריה: משולשים ומרובעים",
115
+ "מדידה ושטחים",
116
+ "הסתברות פשוטה",
117
+ ],
118
+ "expected_level": "בסיסי",
119
+ "explanation_style": "הסברים פשוטים עם דוגמאות מחיי היומיום",
120
+ },
121
+ },
122
+ },
123
+
124
+ # ═══════════════════════════════════════════════════════════════════════
125
+ # כיתה ט'
126
+ # ═══════════════════════════════════════════════════════════════════════
127
+ "ט": {
128
+ "name": "כיתה ט'",
129
+ "age_range": "14-15",
130
+ "levels": {
131
+ "א_מצטיינים": {
132
+ "name": "רמה א' מצטיינים",
133
+ "topics": [
134
+ "פונקציה ריבועית",
135
+ "משוואה ריבועית ונוסחת השורשים",
136
+ "אי-שוויון ריבועי",
137
+ "דמיון משולשים מתקדם",
138
+ "משפט תאלס",
139
+ "טריגונומטריה במשולש ישר-זווית",
140
+ "מעגל: זוויות היקפיות ומרכזיות",
141
+ "הסתברות מתקדמת",
142
+ ],
143
+ "expected_level": "מתקדם - הכנה ל-5 יח\"ל",
144
+ "explanation_style": "הסברים מעמיקים עם הוכחות",
145
+ },
146
+ "א_רגילה": {
147
+ "name": "רמה א' רגילה",
148
+ "topics": [
149
+ "פונקציה ריבועית",
150
+ "משוואה ריבועית",
151
+ "דמיון משולשים",
152
+ "טריגונומטריה בסיסית",
153
+ "מעגל",
154
+ ],
155
+ "expected_level": "סטנדרטי - הכנה ל-4 יח\"ל",
156
+ "explanation_style": "הסברים מסודרים עם תרגול",
157
+ },
158
+ "ב": {
159
+ "name": "רמה ב'",
160
+ "topics": [
161
+ "פונקציה לינארית",
162
+ "משוואות פשוטות",
163
+ "גאומטריה בסיסית",
164
+ "הסתברות פשוטה",
165
+ ],
166
+ "expected_level": "בסיסי - הכנה ל-3 יח\"ל",
167
+ "explanation_style": "הסברים פשוטים ומעודדים",
168
+ },
169
+ },
170
+ },
171
+
172
+ # ═══════════════════════════════════════════════════════════════════════
173
+ # כיתה י' - תחילת לימודי בגרות
174
+ # ═══════════════════════════════════════════════════════════════════════
175
+ "י": {
176
+ "name": "כיתה י'",
177
+ "age_range": "15-16",
178
+ "levels": {
179
+ "3_יחידות": {
180
+ "name": "3 יחידות לימוד",
181
+ "topics": [
182
+ "אלגברה: משוואות ואי-שוויונות",
183
+ "פונקציות: לינארית וריבועית",
184
+ "גאומטריה אנליטית בסיסית",
185
+ "טריגונומטריה במשולש",
186
+ "סטטיסטיקה והסתברות",
187
+ ],
188
+ "bagrut_format": True,
189
+ "expected_level": "בגרות 3 יח\"ל - דגש על יישומים",
190
+ "explanation_style": "הסברים ברורים בפורמט בגרות, דגש על שאלות מילוליות",
191
+ },
192
+ "4_יחידות": {
193
+ "name": "4 יחידות לימוד",
194
+ "topics": [
195
+ "אלגברה מתקד��ת",
196
+ "פונקציות: ריבועית, שורש, ערך מוחלט",
197
+ "גאומטריה אנליטית",
198
+ "טריגונומטריה: משפטי סינוסים וקוסינוסים",
199
+ "סדרות חשבוניות והנדסיות",
200
+ "הסתברות",
201
+ ],
202
+ "bagrut_format": True,
203
+ "expected_level": "בגרות 4 יח\"ל - רמה בינונית-גבוהה",
204
+ "explanation_style": "הסברים מפורטים בפורמט בגרות עם נימוקים",
205
+ },
206
+ "5_יחידות": {
207
+ "name": "5 יחידות לימוד",
208
+ "topics": [
209
+ "אלגברה מתקדמת",
210
+ "פונקציות מורכבות",
211
+ "גאומטריה אנליטית מתקדמת",
212
+ "טריגונומטריה מתקדמת",
213
+ "סדרות והוכחות",
214
+ "מבוא לחשבון דיפרנציאלי",
215
+ ],
216
+ "bagrut_format": True,
217
+ "expected_level": "בגרות 5 יח\"ל - רמה גבוהה",
218
+ "explanation_style": "הסברים מעמיקים עם הוכחות ונימוקים מלאים",
219
+ },
220
+ },
221
+ },
222
+
223
+ # ═══════════════════════════════════════════════════════════════════════
224
+ # כיתה י"א
225
+ # ═══════════════════════════════════════════════════════════════════════
226
+ "יא": {
227
+ "name": "כיתה י\"א",
228
+ "age_range": "16-17",
229
+ "levels": {
230
+ "3_יחידות": {
231
+ "name": "3 יחידות לימוד",
232
+ "topics": [
233
+ "חזרה והעמקה באלגברה",
234
+ "פונקציות ויישומים",
235
+ "סטטיסטיקה מתקדמת",
236
+ "הסתברות",
237
+ "גאומטריה במישור",
238
+ ],
239
+ "bagrut_format": True,
240
+ "expected_level": "הכנה לבגרות 3 יח\"ל",
241
+ "explanation_style": "תרגול בגרויות עם הסברים",
242
+ },
243
+ "4_יחידות": {
244
+ "name": "4 יחידות לימוד",
245
+ "topics": [
246
+ "חדו\"א: נגזרות",
247
+ "חקירת פונקציות פולינומיאליות",
248
+ "גאומטריה אנליטית: ישר ומעגל",
249
+ "טריגונומטריה מתקדמת",
250
+ "סדרות",
251
+ "הסתברות מותנית",
252
+ ],
253
+ "bagrut_format": True,
254
+ "expected_level": "בגרות 4 יח\"ל",
255
+ "explanation_style": "פורמט בגרות מלא עם כל הנימוקים",
256
+ },
257
+ "5_יחידות": {
258
+ "name": "5 יחידות לימוד",
259
+ "topics": [
260
+ "חדו\"א: נגזרות ואינטגרלים",
261
+ "חקירת פונקציות מורכבות",
262
+ "גאומטריה אנליטית מתקדמת",
263
+ "מספרים מרוכבים",
264
+ "הסתברות מתקדמת",
265
+ "אינדוקציה",
266
+ ],
267
+ "bagrut_format": True,
268
+ "expected_level": "בגרות 5 יח\"ל - רמה גבוהה",
269
+ "explanation_style": "הוכחות מלאות, נימוקים פורמליים",
270
+ },
271
+ },
272
+ },
273
+
274
+ # ═══════════════════════════════════════════════════════════════════════
275
+ # כיתה י"ב
276
+ # ═══════════════════════════════════════════════════════════════════════
277
+ "יב": {
278
+ "name": "כיתה י\"ב",
279
+ "age_range": "17-18",
280
+ "levels": {
281
+ "3_יחידות": {
282
+ "name": "3 יחידות לימוד",
283
+ "topics": [
284
+ "חזרה מקיפה לבגרות",
285
+ "פתרון בגרויות קודמות",
286
+ "כל נושאי 3 יח\"ל",
287
+ ],
288
+ "bagrut_format": True,
289
+ "expected_level": "הכנה אינטנסיבית לבגרות",
290
+ "explanation_style": "פתרון בגרויות עם טיפים למב��ן",
291
+ },
292
+ "4_יחידות": {
293
+ "name": "4 יחידות לימוד",
294
+ "topics": [
295
+ "חזרה מקיפה לבגרות",
296
+ "חדו\"א: אינטגרלים ושטחים",
297
+ "גאומטריה אנליטית מתקדמת",
298
+ "פתרון בגרויות",
299
+ ],
300
+ "bagrut_format": True,
301
+ "expected_level": "הכנה אינטנסיבית לבגרות 4 יח\"ל",
302
+ "explanation_style": "פתרון מבחנים עם אסטרטגיות",
303
+ },
304
+ "5_יחידות": {
305
+ "name": "5 יחידות לימוד",
306
+ "topics": [
307
+ "חדו\"א מתקדם",
308
+ "אינטגרלים ויישומים",
309
+ "משוואות דיפרנציאליות פשוטות",
310
+ "גאומטריה אנליטית: אליפסה והיפרבולה",
311
+ "מספרים מרוכבים מתקדם",
312
+ "וקטורים",
313
+ "הכנה לבגרות",
314
+ ],
315
+ "bagrut_format": True,
316
+ "expected_level": "הכנה אינטנסיבית לבגרות 5 יח\"ל",
317
+ "explanation_style": "פתרון מלא עם כל ההוכחות והנימוקים",
318
+ },
319
+ },
320
+ },
321
+ }
322
+
323
+
324
+ # =============================================================================
325
+ # כללי בגרות לפי נושא (מתוך מסמך משרד החינוך)
326
+ # =============================================================================
327
+
328
+ BAGRUT_RULES = {
329
+ "גאומטריה": {
330
+ "חובה": [
331
+ "לנמק בהגדרות ומשפטים גאומטריים בלבד",
332
+ "לציין את המשולש/מרובע שאליו מתייחסים",
333
+ "להגדיר זווית ב-3 אותיות או אות אחת + שם המשולש",
334
+ ],
335
+ "לא_נדרש": [
336
+ "'כל גודל שווה לעצמו'",
337
+ "'כלל החיבור'",
338
+ "'כלל החיסור'",
339
+ "לרשום שקטעים מקבילים אם כתבנו שישרים מקבילים",
340
+ ],
341
+ "נימוקים_נפוצים": [
342
+ "נתון",
343
+ "זוויות מתחלפות בין ישרים מקבילים",
344
+ "זוויות חד-צדדיות משלימות ל-180°",
345
+ "זוויות קודקודיות שוות",
346
+ "סכום זוויות במשולש = 180°",
347
+ "משפט פיתגורס",
348
+ "משפט תאלס",
349
+ "חפיפת משולשים (צ.ז.צ / ז.צ.ז / צ.צ.צ)",
350
+ "דמיון משולשים (ז.ז / צ.ז.צ / צ.צ.צ)",
351
+ ],
352
+ },
353
+
354
+ "חדוא": {
355
+ "חובה": [
356
+ "תחום הגדרה: לכתוב אי-שוויון נכון (חזק ≠ חלש)",
357
+ "באינטגרל: לכתוב פונקציה קדומה + הצבת גבולות",
358
+ "לכתוב dx וסוגריים במקום הנכון",
359
+ ],
360
+ "לא_נדרש": [
361
+ "לפרט חישובי גבולות לאסימפטוטות",
362
+ "לפרט הצבות בקביעת סוג קיצון",
363
+ ],
364
+ "טעויות_קריטיות": [
365
+ "תחום הגדרה עם כיוון אי-שוויון שגוי = 0 נקודות",
366
+ "אינטגרל ללא פונקציה קדומה = 0 נקודות",
367
+ ],
368
+ },
369
+
370
+ "הסתברות": {
371
+ "חובה": [
372
+ "להגדיר כל מאורע (A, B) במילים",
373
+ "בהסתברות מותנית: לרשום P(B|A) ואז להציב בנוסחה",
374
+ "להראות כל חישוב",
375
+ ],
376
+ },
377
+
378
+ "אלגברה": {
379
+ "חובה": [
380
+ "להראות דרך פתרון מלאה",
381
+ "תשובה סופית ללא דרך = לא נבדק",
382
+ "במשוואה ריבועית: להראות נוסחה או פירוק",
383
+ ],
384
+ },
385
+
386
+ "אינדוקציה": {
387
+ "חובה": [
388
+ "משפטי קישור: 'נבדוק עבור n=1', 'נניח עבור n=k', 'נוכיח עבור n=k+1'",
389
+ "לרשום נכון מה צריך להוכיח",
390
+ ],
391
+ "טעויות_קריטיות": [
392
+ "'נניח לכל n טבעי' = הורדה של 20%",
393
+ "רישום שגוי של מה שצריך להוכיח = לא בודקים המשך",
394
+ ],
395
+ },
396
+
397
+ "טריגונומטריה": {
398
+ "4_יחידות": [
399
+ "לפשט sin(180°-x) וכדומה",
400
+ ],
401
+ "5_יחידות": [
402
+ "לעבוד ברדיאנים בלבד (מעלות = 0 נקודות)",
403
+ ],
404
+ },
405
+
406
+ "סדרות": {
407
+ "חובה": [
408
+ "לרשום את סוג הסדרה (חשבונית/הנדסית) ואת הנוסחה הכללית",
409
+ "בסכום חלקי: לרשום את הנוסחה ולהציב",
410
+ "בהוכחת נוסחה: להשתמש באינדוקציה מלאה",
411
+ ],
412
+ },
413
+
414
+ "מספרים_מרוכבים": {
415
+ "חובה": [
416
+ "לרשום z בצורה אלגברית (a+bi) וגם בצורה טריגונומטרית אם נדרש",
417
+ "להראות חישוב מודולוס וארגומנט",
418
+ "בשורשים: להשתמש בנוסחת דה-מואבר ולרשום את כל השורשים",
419
+ ],
420
+ },
421
+
422
+ "וקטורים": {
423
+ "חובה": [
424
+ "לרשום וקטור כצמד מוסדר",
425
+ "במכפלה סקלרית: להראות חישוב בשתי דרכים (קואורדינטות וזווית)",
426
+ "להוכיח ניצבות דרך מכפלה סקלרית = 0",
427
+ ],
428
+ },
429
+
430
+ "סטטיסטיקה": {
431
+ "חובה": [
432
+ "לרשום את נוסחת הממוצע/שונות/סטיית תקן",
433
+ "בהתפלגות נורמלית: לציין Z-score ולהשתמש בטבלאות",
434
+ "בהתפלגות בינומית: לרשום n, p, ולהציב בנוסחה",
435
+ ],
436
+ },
437
+ }
438
+
439
+
440
+ # =============================================================================
441
+ # סגנונות הסבר לפי גיל
442
+ # =============================================================================
443
+
444
+ EXPLANATION_STYLES = {
445
+ "חטיבת_ביניים": {
446
+ "שפה": "פשוטה ונגישה",
447
+ "דוגמאות": "מחיי היומיום",
448
+ "עידוד": "הרבה חיזוקים חיוביים",
449
+ "קצב": "איטי ומפורט",
450
+ "אימוג'ים": "כן, בכמות מתונה",
451
+ },
452
+ "חטיבה_עליונה_3_יחידות": {
453
+ "שפה": "ברורה עם מונחים מתמטיים בסיסיים",
454
+ "דוגמאות": "יישומיות",
455
+ "עידוד": "מתון",
456
+ "קצב": "רגיל",
457
+ "פורמט": "בגרות",
458
+ },
459
+ "חטיבה_עליונה_4_יחידות": {
460
+ "שפה": "מתמטית עם הסברים",
461
+ "דוגמאות": "מתמטיות",
462
+ "עידוד": "ממוקד",
463
+ "קצב": "רגיל-מהיר",
464
+ "פורמט": "בגרות מלא",
465
+ },
466
+ "חטיבה_עליונה_5_יחידות": {
467
+ "שפה": "מתמטית פורמלית",
468
+ "דוגמאות": "הוכחות ונימוקים",
469
+ "עידוד": "מינימלי",
470
+ "קצב": "מהיר",
471
+ "פורמט": "בגרות מלא עם הוכחות",
472
+ },
473
+ }
474
+
475
+
476
+ # =============================================================================
477
+ # פונקציות עזר
478
+ # =============================================================================
479
+
480
+ def get_grade_profile(grade: str, level: str = None) -> dict:
481
+ """מחזיר פרופיל כיתה ורמה"""
482
+
483
+ # נרמול שם הכיתה
484
+ grade_map = {
485
+ "ז'": "ז", "ז": "ז", "7": "ז",
486
+ "ח'": "ח", "ח": "ח", "8": "ח",
487
+ "ט'": "ט", "ט": "ט", "9": "ט",
488
+ "י'": "י", "י": "י", "10": "י",
489
+ "י\"א": "יא", "יא": "יא", "11": "יא",
490
+ "י\"ב": "יב", "יב": "יב", "12": "יב",
491
+ }
492
+
493
+ grade_key = grade_map.get(grade, "י")
494
+ profile = GRADE_PROFILES.get(grade_key, GRADE_PROFILES["י"])
495
+
496
+ # נרמול רמה
497
+ if level:
498
+ level_map = {
499
+ "3 יח\"ל": "3_יחידות", "3 יחידות": "3_יחידות", "3": "3_יחידות",
500
+ "4 יח\"ל": "4_יחידות", "4 יחידות": "4_יחידות", "4": "4_יחידות",
501
+ "5 יח\"ל": "5_יחידות", "5 יחידות": "5_יחידות", "5": "5_יחידות",
502
+ "רמה א' מצטיינים": "א_מצטיינים", "מצטיינים": "א_מצטיינים",
503
+ "רמה א' רגילה": "א_רגילה", "רגילה": "א_רגילה",
504
+ "רמה ב'": "ב", "ב": "ב",
505
+ }
506
+ level_key = level_map.get(level, list(profile["levels"].keys())[0])
507
+ level_profile = profile["levels"].get(level_key, list(profile["levels"].values())[0])
508
+ else:
509
+ level_profile = list(profile["levels"].values())[0]
510
+
511
+ return {
512
+ "grade": profile,
513
+ "level": level_profile,
514
+ }
515
+
516
+
517
+ def get_explanation_style(grade: str, level: str = None) -> dict:
518
+ """מחזיר סגנון הסבר מותאם"""
519
+
520
+ profile = get_grade_profile(grade, level)
521
+
522
+ # זיהוי אם חטיבת ביניים או עליונה
523
+ if grade in ["ז", "ז'", "ח", "ח'", "ט", "ט'", "7", "8", "9"]:
524
+ return EXPLANATION_STYLES["חטיבת_ביניים"]
525
+
526
+ # חטיבה עליונה - לפי יחידות
527
+ level_name = profile["level"].get("name", "")
528
+ if "3 יחידות" in level_name:
529
+ return EXPLANATION_STYLES["חטיבה_עליונה_3_יחידות"]
530
+ elif "4 יחידות" in level_name:
531
+ return EXPLANATION_STYLES["חטיבה_עליונה_4_יחידות"]
532
+ else:
533
+ return EXPLANATION_STYLES["חטיבה_עליונה_5_יחידות"]
534
+
535
+
536
+ def get_bagrut_rules(topic: str) -> dict:
537
+ """מחזיר כללי בגרות לנושא מסוים"""
538
+
539
+ topic_map = {
540
+ "גאומטריה": "גאומטריה",
541
+ "geometry": "גאומטריה",
542
+ "חדוא": "חדוא",
543
+ "calculus": "חדוא",
544
+ "חקירה": "חדוא",
545
+ "נגזרת": "חדוא",
546
+ "אינטגרל": "חדוא",
547
+ "הסתברות": "הסתברות",
548
+ "probability": "הסתברות",
549
+ "אלגברה": "אלגברה",
550
+ "algebra": "אלגברה",
551
+ "משוואה": "אלגברה",
552
+ "אינדוקציה": "אינדוקציה",
553
+ "induction": "אינדוקציה",
554
+ "טריגונומטריה": "טריגונומטריה",
555
+ "trigonometry": "טריגונומטריה",
556
+ "סדרה": "סדרות",
557
+ "סדרות": "סדרות",
558
+ "sequence": "סדרות",
559
+ "מרוכב": "מספרים_מרוכבים",
560
+ "complex": "מספרים_מרוכבים",
561
+ "וקטור": "וקטורים",
562
+ "vector": "וקטורים",
563
+ "סטטיסטיקה": "סטטיסטיקה",
564
+ "statistics": "סטטיסטיקה",
565
+ "ממוצע": "סטטיסטיקה",
566
+ "התפלגות": "סטטיסטיקה",
567
+ }
568
+
569
+ for key, value in topic_map.items():
570
+ if key in topic.lower():
571
+ return BAGRUT_RULES.get(value, {})
572
+
573
+ return {}
574
+
575
+
576
+ def is_bagrut_level(grade: str, level: str = None) -> bool:
577
+ """בודק אם זו רמת בגרות"""
578
+ profile = get_grade_profile(grade, level)
579
+ return profile["level"].get("bagrut_format", False)
debug_llm_payload.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import os
3
+ import sys
4
+
5
+ # Set encoding for Windows console (just in case)
6
+ if sys.platform == 'win32':
7
+ sys.stdout.reconfigure(encoding='utf-8')
8
+ sys.stderr.reconfigure(encoding='utf-8')
9
+
10
+ from orchestrator import BuddyOrchestrator
11
+ from domain.processing_strategy import ProcessingStrategy
12
+ from smart_solver import SmartSolver
13
+
14
+ async def debug_orchestrator():
15
+ orchestrator = BuddyOrchestrator()
16
+ problem_text = "פתור את המשוואה הטריגונומטרית: sin x = 0.5"
17
+ grade = "10"
18
+ student_name = "דוד"
19
+
20
+ print("🚀 Starting Smart Solver Trace...")
21
+ try:
22
+ data_anchor = {"function_equations": ["sin x = 0.5"]}
23
+ result = await orchestrator.smart_solve(
24
+ problem_text=problem_text,
25
+ data_anchor=data_anchor,
26
+ grade=grade,
27
+ category="ALGEBRA",
28
+ processing_strategy=ProcessingStrategy.STRICT_SYMBOLIC
29
+ )
30
+
31
+ import json
32
+ with open("debug_output.json", "w", encoding="utf-8") as f:
33
+ json.dump(result, f, indent=2, ensure_ascii=False)
34
+ print("✅ Saved to debug_output.json")
35
+
36
+ except Exception as e:
37
+ print(f"❌ Error during execution: {e}")
38
+ import traceback
39
+ traceback.print_exc()
40
+
41
+ if __name__ == "__main__":
42
+ asyncio.run(debug_orchestrator())
domain/curriculum_classifier.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # domain/curriculum_classifier.py
2
+ import sympy as sp
3
+ import logging
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+ class CurriculumClassifier:
8
+ """
9
+ V6.1 Hybrid Architecture - Phase 1: Router Layer
10
+ Evaluates math complexity and injects grade-specific pedagogical prompts.
11
+ """
12
+
13
+ @staticmethod
14
+ def estimate_complexity(math_string: str) -> int:
15
+ """
16
+ Calculates a complexity score (1-10) based on AST features.
17
+ """
18
+ score = 1
19
+ try:
20
+ # Parse multiple equations if necessary
21
+ expressions = []
22
+
23
+ # Helper to quickly strip common LaTeX that crashes sympify
24
+ import re
25
+ cleaned_str = re.sub(r'\\[a-zA-Z]+', '', str(math_string))
26
+ cleaned_str = cleaned_str.replace('{', '(').replace('}', ')').replace('$', '')
27
+
28
+ for part in cleaned_str.split(','):
29
+ expressions.append(sp.sympify(part.replace('=', '-'), evaluate=False))
30
+
31
+ for expr in expressions:
32
+ # Number of variables
33
+ vars_count = len(expr.free_symbols)
34
+ if vars_count > 1:
35
+ score += vars_count
36
+
37
+ # Number of operations / tree depth mapping (approx)
38
+ ops_count = expr.count_ops()
39
+ score += ops_count // 3
40
+
41
+ # Advanced functions (Trigonometry, Logarithms)
42
+ if expr.has(sp.sin, sp.cos, sp.tan, sp.log, sp.exp):
43
+ score += 3
44
+
45
+ # High degree polynomials
46
+ if expr.is_polynomial():
47
+ degrees = sp.degree_list(expr)
48
+ max_deg = max(degrees) if degrees else 0
49
+ if max_deg > 2:
50
+ score += 2
51
+
52
+ except Exception as e:
53
+ # Fallback heuristic if SymPy parse fails entirely
54
+ raw_len = len(str(math_string))
55
+ fallback_score = 3 + (raw_len // 15)
56
+ logger.warning(f"Complexity estimation failed, falling back to len heuristic ({fallback_score}): {e}")
57
+ score = fallback_score
58
+
59
+
60
+ final_score = min(max(int(score), 1), 10)
61
+ logger.info(f"[ROUTER] Intended Math Complexity Score: {final_score}/10")
62
+ return final_score
63
+
64
+ @staticmethod
65
+ def get_prompt_specialization(grade: str, intent_category: str) -> str:
66
+ """
67
+ Returns pedagogical prompt constraints based on the student's grade.
68
+ """
69
+ grade_num = 0
70
+ import re
71
+ match = re.search(r'\d+', str(grade))
72
+ if match:
73
+ grade_num = int(match.group())
74
+
75
+ constraints = []
76
+
77
+ # Base setup
78
+ constraints.append("אתה מורה פרטי למתמטיקה.")
79
+
80
+ # Grade specific logic
81
+ if grade_num <= 7:
82
+ constraints.append("הסבר במונחים פשוטים מאוד. אל תשתמש במילים כמו 'נבודד' או 'נפשט', אלא 'נשאיר את הנעלם לבד' או 'נחשב את המספרים'.")
83
+ elif grade_num <= 9:
84
+ constraints.append("השתמש במונחי אלגברה תקניים כמו 'העברת אגפים', 'כינוס איברים דומים', ו'בידוד משתנה'. שמור על שפה מעודדת.")
85
+ elif grade_num <= 12:
86
+ constraints.append("הנח כי התלמיד מכיר מושגי יסוד באלגברה. התמקד בהיגיון העמוק של התרגיל ובחוקים המתמטיים (למשל חוקי חזקות, זהויות). הייה רשמי ומדויק.")
87
+ if "TRIGONOMETRY" in intent_category.upper():
88
+ constraints.append("במשוואות טריגונומטריות, הבהר תמיד את שיקולי המחזוריות או מעגל היחידה.")
89
+ else:
90
+ constraints.append("הסבר בבהירות ובצורה ישירה.")
91
+
92
+ specialization = " ".join(constraints)
93
+ logger.info(f"[ROUTER] Prompt specialization applied for Grade {grade} ({intent_category})")
94
+ return specialization
domain/math_normalizer.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # domain/math_normalizer.py
2
+
3
+ import re
4
+ import logging
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ class MathCanonicalizer:
9
+ @staticmethod
10
+ def preprocess_ocr_string(math_string: str) -> str:
11
+ """
12
+ V6 Polish (P0): Aggressively cleans raw OCR math strings before SymPy parsing.
13
+ - Adds explicit multiplication.
14
+ - Wraps trigonometric functions in parentheses (e.g., sin x -> sin(x)).
15
+ - Normalizes basic artifacts.
16
+ """
17
+ if not math_string:
18
+ return ""
19
+
20
+ clean_str = str(math_string).strip()
21
+
22
+ # 1. Standardize Whitespace
23
+ clean_str = re.sub(r'\s+', ' ', clean_str)
24
+
25
+ # 2. Add implicit multiplication between numbers and variables (e.g., 2x -> 2*x)
26
+ # Note: SymPy handles some of this, but we do it explicitly to be safe
27
+ clean_str = re.sub(r'(\d)([a-zA-Z])', r'\1*\2', clean_str)
28
+
29
+ # 3. Trigonometric Functions Parentheses Guard
30
+ # Matches sin, cos, tan, cot, arcsin, arccos, arctan, etc. followed by space and a variable/number
31
+ # Example: 'sin x' -> 'sin(x)', 'cos 2x' -> 'cos(2*x)', 'tan ^2x' is trickier but we handle the basics.
32
+ trig_funcs = r'(sin|cos|tan|cot|sec|csc|arcsin|arccos|arctan)'
33
+
34
+ # Match 'sin x' or 'sin 2*x' but not 'sin(x)'
35
+ # This regex looks for trig function, optional space, and then something that is not a parenthesis
36
+ clean_str = re.sub(fr'{trig_funcs}\s+([a-zA-Z0-9_*]+)(?!\()', r'\1(\2)', clean_str)
37
+
38
+ return clean_str
domain/math_validator.py ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # domain/math_validator.py — V1.0 (SYMPY POLYGRAPH)
2
+ """
3
+ CTO Directive: "SymPy Polygraph" — validates LLM math claims before releasing to Flutter.
4
+
5
+ Rules:
6
+ 1. Scans ALL steps (including GEOMETRY) for math_latex fields — no exemptions.
7
+ 2. Fail-CLOSED: SympifyError → (False, "SymPy Parsing Failed"). Never swallow errors.
8
+ 3. 3-second hard timeout on every SymPy call (prevents Resource Exhaustion from
9
+ LLM-hallucinated expressions like x**1000 that would stall the CPU).
10
+ 4. Consecutive algebraic pairs are equivalence-checked via sympy.simplify.
11
+ """
12
+
13
+ import re
14
+ import logging
15
+ import multiprocessing
16
+ import time
17
+ import asyncio
18
+ from typing import Tuple, List
19
+
20
+ import sympy
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # Expressions that are structurally unparseable but pedagogically harmless
25
+ # (e.g. "d = 5 | r = 5 | על המעגל")
26
+ _PIPE_SEPARATED_RESULT = re.compile(r'\|')
27
+ _HEBREW_ONLY = re.compile(r'^[\u0590-\u05FF\s]+$')
28
+
29
+ # LaTeX commands that SymPy cannot parse — strip them before sympify
30
+ _LATEX_STRIP = re.compile(
31
+ r'\\(?:frac|left|right|cdot|times|div|sqrt|pm|mp|leq|geq|neq'
32
+ r'|approx|infty|pi|theta|alpha|beta|gamma|delta|sin|cos|tan|log|ln'
33
+ r'|text|mathrm|mathbf|boxed|underbrace|overbrace|hat|bar|vec|dot|overline|underline)\b'
34
+ )
35
+
36
+
37
+ def _latex_to_sympy_str(latex_str: str) -> str:
38
+ """
39
+ Best-effort LaTeX → SymPy-parseable string.
40
+ Not a full LaTeX parser — just cleans the most common patterns.
41
+ """
42
+ s = latex_str.strip()
43
+ # Handle \frac{a}{b} → (a)/(b) (Safe version with Kill-Switch)
44
+ loop_counter = 0
45
+ max_loops = 15
46
+ while r'\frac' in s and loop_counter < max_loops:
47
+ old_s = s
48
+ s = re.sub(r'\\frac\s*\{([^{}]*)\}\s*\{([^{}]*)\}', r'(\1)/(\2)', s)
49
+ if old_s == s:
50
+ s = s.replace(r'\frac', '(frac_err)')
51
+ break
52
+ loop_counter += 1
53
+ # Remove remaining LaTeX commands
54
+ s = _LATEX_STRIP.sub(' ', s)
55
+ # Remove LaTeX delimiters
56
+ s = s.replace('{', '(').replace('}', ')').replace('$', '')
57
+ s = s.replace(r'\left', '').replace(r'\right', '')
58
+
59
+ # Handle absolute value pipes |x| -> Abs(x)
60
+ # Loop to capture nested or multiple Abs pairs
61
+ loop_counter = 0
62
+ while '|' in s and s.count('|') >= 2 and loop_counter < 10:
63
+ s = re.sub(r'\|([^|]+)\|', r'Abs(\1)', s)
64
+ loop_counter += 1
65
+
66
+ # Implicit multiplication: 2x → 2*x
67
+ s = re.sub(r'(\d)([a-zA-Z(])', r'\1*\2', s)
68
+ # Handle equals sign (for equations)
69
+ if '=' in s:
70
+ parts = s.split('=', 1)
71
+ s = f"({parts[0]}) - ({parts[1]})"
72
+ return s.strip()
73
+
74
+
75
+ def _is_plaintext(expr_str: str) -> bool:
76
+ """Returns True if the expression is plain text (Hebrew / pipe-separated), not math."""
77
+ if _HEBREW_ONLY.match(expr_str):
78
+ return True
79
+ if _PIPE_SEPARATED_RESULT.search(expr_str) and not any(c in expr_str for c in ['+', '-', '*', '/', '^', '=']):
80
+ return True
81
+ return False
82
+
83
+
84
+ class MathPolygraph:
85
+ """
86
+ Post-processing validator that spot-checks LLM math_latex fields using SymPy.
87
+ Called AFTER safe_extract_json, BEFORE the JSON is released to Flutter.
88
+
89
+ Design:
90
+ - validate_step_sequence() is the only public API.
91
+ - Returns (True, "") on clean pass.
92
+ - Returns (False, reason) on hallucination or parse failure.
93
+ - The orchestrator must set logic_error=True and return a hint response
94
+ when this returns False.
95
+ """
96
+
97
+ TIMEOUT_SECONDS = 3
98
+
99
+ @staticmethod
100
+ def _sympify_worker(expr_str: str, queue: multiprocessing.Queue):
101
+ """Worker function for multiprocessing."""
102
+ try:
103
+ # We don't actually need the result object, just to know if it PARSES
104
+ # without hanging the entire server.
105
+ sympy.sympify(expr_str, evaluate=False)
106
+ queue.put(True)
107
+ except Exception:
108
+ queue.put(False)
109
+
110
+ @staticmethod
111
+ def _sympify_with_timeout(expr_str: str) -> bool:
112
+ """
113
+ V8.6.6: Runs sympy.sympify in an isolated PROCESS with a hard 2-second timeout.
114
+ Returns:
115
+ True: Parse successful.
116
+ False: Parse failed (SymPy error).
117
+ None: Process timed out (Hard Kill).
118
+ """
119
+ queue = multiprocessing.Queue()
120
+ process = multiprocessing.Process(
121
+ target=MathPolygraph._sympify_worker,
122
+ args=(expr_str, queue)
123
+ )
124
+
125
+ try:
126
+ process.start()
127
+ # Wait for 2 seconds
128
+ process.join(timeout=2)
129
+
130
+ if process.is_alive():
131
+ logger.error(f"🛑 [POLYGRAPH] SymPy DEADLOCK detected! Hard-killing process for: {expr_str[:50]}...")
132
+ process.terminate()
133
+ process.join() # Clean up
134
+ return None # TIMEOUT
135
+
136
+ if not queue.empty():
137
+ return queue.get()
138
+ return False # Fallthrough to error
139
+ except Exception as e:
140
+ logger.error(f"⚠️ [POLYGRAPH] Multiprocessing error: {e}")
141
+ if process.is_alive():
142
+ process.terminate()
143
+ return False
144
+
145
+ @staticmethod
146
+ async def _validate_single(math_latex: str, step_id) -> Tuple[bool, str]:
147
+ """
148
+ Validates that a single math_latex string is SymPy-parseable.
149
+ Fail-CLOSED: any error → (False, reason).
150
+ """
151
+ if not math_latex or not math_latex.strip():
152
+ # Empty math field is allowed (some steps have no math)
153
+ return True, ""
154
+
155
+ raw = str(math_latex).strip()
156
+
157
+ # Plain text results (e.g. Hebrew labels, pipe-separated geometry) — skip SymPy
158
+ if _is_plaintext(raw):
159
+ logger.info(f"[POLYGRAPH] Step {step_id}: plain-text field, skipping SymPy.")
160
+ return True, ""
161
+
162
+ sympy_str = _latex_to_sympy_str(raw)
163
+ if not sympy_str or sympy_str in ('', '-', '()', '( ) - ( )'):
164
+ logger.warning(f"[POLYGRAPH] Step {step_id}: empty after LaTeX strip, skipping.")
165
+ return True, ""
166
+
167
+ try:
168
+ # V8.6.7: Run blocking multiprocessing in a separate thread to keep Event Loop alive
169
+ status = await asyncio.to_thread(MathPolygraph._sympify_with_timeout, sympy_str)
170
+
171
+ if status is True:
172
+ return True, ""
173
+ elif status is None:
174
+ # V8.6.6: Bypass Timeout — Always return True so SSE stream continues!
175
+ logger.warning(f"[POLYGRAPH] 🕐 Step {step_id}: SymPy TIMEOUT bypass triggered.")
176
+ return True, f"SYMPY_TIMEOUT:step_{step_id}"
177
+ else:
178
+ # Parse error
179
+ return False, f"SYMPY_PARSE_ERROR:step_{step_id}"
180
+
181
+ except Exception as e:
182
+ # Updated Regex for Geometry and Logic Bypass
183
+ is_geometry_or_logic = bool(re.search(r'\\parallel|\\angle|\\triangle|\\cong|\\sim|\\perp|\\Rightarrow|\\implies|\\rightarrow|\\text', raw))
184
+
185
+ if is_geometry_or_logic:
186
+ logger.info(f"🛡️ [POLYGRAPH] Skipping SymPy validation for Geometry/Logic expression: {raw}")
187
+ # זה גיאומטריה או לוגיקה, SymPy לא רלוונטי כאן. אנחנו מאשרים מעבר.
188
+ return True, ""
189
+ else:
190
+ logger.error(f"[POLYGRAPH] ❌ Step {step_id}: Unexpected SymPy error: {e}")
191
+ return True, "" # Soft-fail on unexpected errors too
192
+
193
+ @staticmethod
194
+ async def validate_step_sequence(steps: List[dict]) -> Tuple[bool, str]:
195
+ """
196
+ Public API. Scans ALL steps (GEOMETRY included) for math_latex fields.
197
+
198
+ For each step:
199
+ 1. Validates the math_latex is SymPy-parseable (fail-closed).
200
+
201
+ For consecutive algebraic step pairs (both have math_latex):
202
+ 2. Checks algebraic equivalence via sympy.simplify (spot-check for hallucinations).
203
+
204
+ Args:
205
+ steps: List of step dicts. Each may have a 'math_latex' or 'block_math' field.
206
+
207
+ Returns:
208
+ (True, "") if all checks pass.
209
+ (False, reason) on first failure.
210
+ """
211
+ if not steps:
212
+ return True, ""
213
+
214
+ logger.info(f"[POLYGRAPH] 🔬 Scanning {len(steps)} step(s) for math integrity...")
215
+
216
+ prev_expr = None
217
+ prev_sympy = None
218
+ prev_id = None
219
+
220
+ for step in steps:
221
+ step_id = step.get('step_id', step.get('step_number', '?'))
222
+
223
+ # Collect all math fields — check both new (math_latex) and legacy (block_math)
224
+ math_fields = []
225
+ for field in ('math_latex', 'block_math', 'math'):
226
+ val = step.get(field)
227
+ if val and isinstance(val, str) and val.strip():
228
+ math_fields.append((field, val.strip()))
229
+
230
+ # Also check nested math_artifact.latex
231
+ artifact = step.get('math_artifact', {})
232
+ if isinstance(artifact, dict):
233
+ artifact_latex = artifact.get('latex', '')
234
+ if artifact_latex and artifact_latex.strip():
235
+ math_fields.append(('math_artifact.latex', artifact_latex.strip()))
236
+
237
+ if not math_fields:
238
+ prev_expr = None
239
+ prev_sympy = None
240
+ continue
241
+
242
+ # Validate primary math field
243
+ primary_field, primary_val = math_fields[0]
244
+
245
+ ok, reason = await MathPolygraph._validate_single(primary_val, step_id)
246
+ if not ok:
247
+ return False, reason
248
+
249
+ # ── Consecutive equivalence check ────────────────────────────────
250
+ # If the LLM claims step N+1 follows from step N, verify they're related
251
+ # (Skip plain-text and Hebrew-only results)
252
+ if prev_expr is not None and prev_sympy is not None and not _is_plaintext(primary_val):
253
+ curr_sympy_str = _latex_to_sympy_str(primary_val)
254
+ if curr_sympy_str:
255
+ try:
256
+ # V8.6.7: Run in thread to prevent blocking
257
+ curr_sympy = await asyncio.to_thread(MathPolygraph._sympify_with_timeout, curr_sympy_str)
258
+ # 🚨 HOTFIX: Removed sympy.simplify() to prevent CPU Deadlocks.
259
+ # We only check for valid parseable syntax.
260
+ prev_sympy = curr_sympy
261
+ prev_expr = primary_val
262
+ prev_id = step_id
263
+ except (concurrent.futures.TimeoutError, sympy.SympifyError, Exception) as e:
264
+ # Reset chain on failure (don't propagate equivalence errors)
265
+ prev_sympy = None
266
+ prev_expr = None
267
+ else:
268
+ # Try to build the SymPy object for the next comparison
269
+ if not _is_plaintext(primary_val):
270
+ try:
271
+ curr_str = _latex_to_sympy_str(primary_val)
272
+ if curr_str:
273
+ # V8.6.7: Run in thread to prevent blocking
274
+ prev_sympy = await asyncio.to_thread(MathPolygraph._sympify_with_timeout, curr_str)
275
+ prev_expr = primary_val
276
+ prev_id = step_id
277
+ except Exception:
278
+ prev_sympy = None
279
+ prev_expr = None
280
+
281
+ logger.info(f"[POLYGRAPH] ✅ All {len(steps)} steps passed math integrity check.")
282
+ return True, ""
domain/ontology.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # domain/ontology.py
2
+
3
+ GLOBAL_ONTOLOGY = {
4
+ "algebra": {
5
+ "solve_linear_system": {
6
+ "concepts": ["מערכת", "מערכת משוואות", "חיתוך ישרים", "השוואת ביטויים", "נעלמים", "נציב", "נחלץ", "משוואות", "משתנה", "ערך"],
7
+ "tag": "השוואה"
8
+ },
9
+ "isolate_variable": {
10
+ "concepts": ["בידוד משתנה", "העברת אגפים", "נחלק במקדם", "נחסר", "נכנס איברים דומים", "נפשט", "איברים"],
11
+ "tag": "בידוד משתנה"
12
+ },
13
+ "substitute": {
14
+ "concepts": ["הצבה", "נציב את הערך", "משוואה מקורית", "ערך ה-y", "ערך ה-x", "משתנה שני"],
15
+ "tag": "הצבה"
16
+ },
17
+ "general_algebra": {
18
+ "concepts": ["משוואה", "נעלם", "פתרון", "הסבר", "חישוב", "שלב"],
19
+ "tag": "אלגברה בסיסית"
20
+ }
21
+ },
22
+ "geometry": {
23
+ # Geometry terms will go here later
24
+ }
25
+ }
26
+
27
+ def get_allowed_concepts(category: str, rule_id: str) -> list[str]:
28
+ """Fetch allowed concepts from ontology"""
29
+ domain = GLOBAL_ONTOLOGY.get(category.lower(), {})
30
+ rule_data = domain.get(rule_id, domain.get("general_algebra", {"concepts": []}))
31
+ return rule_data.get("concepts", [])
32
+
33
+ def get_pedagogical_tag(category: str, rule_id: str) -> str:
34
+ """Fetch pedagogical tag from ontology"""
35
+ domain = GLOBAL_ONTOLOGY.get(category.lower(), {})
36
+ rule_data = domain.get(rule_id, domain.get("general_algebra", {"tag": "כללי"}))
37
+ return rule_data.get("tag", "כללי")
domain/pedagogical_renderer.py ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # domain/pedagogical_renderer.py - V7.2 (Deterministic Governed Reasoning Runtime)
2
+ import re
3
+ import logging
4
+ from typing import List, Optional
5
+ import domain.telemetry as telemetry
6
+ from domain.semantic_bank import get_diversity_engine
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+
12
+ # ==================== V7.2: PLACEHOLDER GUARD ====================
13
+
14
+ def validate_placeholders(renderer_text: str, provided_ids: List[str]) -> bool:
15
+ """
16
+ V7.2: Bi-directional placeholder validation.
17
+
18
+ Check 1: Every ID the server provided MUST appear in the text as {{id}}.
19
+ Check 2: No placeholder the LLM invented (not in provided_ids) may appear.
20
+
21
+ Either failure → Fail Closed (Hint Mode).
22
+ """
23
+ # Check 1: All provided IDs must be referenced
24
+ for step_id in provided_ids:
25
+ expected = "{{" + step_id + "}}"
26
+ if expected not in renderer_text:
27
+ logger.warning(
28
+ f"[RENDERER GUARD] Missing placeholder for server-provided ID '{step_id}'. "
29
+ f"Expected '{expected}' in renderer output."
30
+ )
31
+ telemetry.emit_renderer_placeholder_violation("missing", step_id)
32
+ return False
33
+
34
+ # Check 2: No invented placeholders allowed
35
+ found_placeholders = re.findall(r'\{\{(\w+)\}\}', renderer_text)
36
+ for ph in found_placeholders:
37
+ if ph not in provided_ids:
38
+ logger.warning(
39
+ f"[RENDERER GUARD] Invented placeholder '{{{{{{ph}}}}}}' detected! "
40
+ f"Not in server-provided IDs: {provided_ids}"
41
+ )
42
+ telemetry.emit_renderer_placeholder_violation("invented", ph)
43
+ return False
44
+
45
+ return True
46
+
47
+
48
+ def inject_signed_results(renderer_text: str, signed_steps: List[dict]) -> str:
49
+ """
50
+ Replaces {{step_id}} placeholders in the renderer's text with the actual
51
+ computed expressions from the server's signed step list.
52
+ Only called AFTER validate_placeholders() passes.
53
+ """
54
+ result = renderer_text
55
+ for step in signed_steps:
56
+ placeholder = "{{" + step["id"] + "}}"
57
+ result = result.replace(placeholder, f"`{step['expression']}`")
58
+ return result
59
+
60
+
61
+ # ==================== V7.2: PEDAGOGICAL RENDERER ====================
62
+
63
+ class PedagogicalRenderer:
64
+ """
65
+ V7.2: LLM #2 — Pedagogical Renderer.
66
+
67
+ Receives the server's signed step IDs (NOT expressions) and the Planner's rationale.
68
+ Produces a pedagogical explanation in Hebrew using only {{step_id}} placeholders.
69
+ Never computes or writes math itself.
70
+ """
71
+
72
+ def __init__(self, llm_gateway):
73
+ self.llm_gateway = llm_gateway
74
+
75
+ # V7.2.4 PROMPT — Updated by CTO: Pedagogical Narrator framing + 1 approved example.
76
+ # Any further changes require full regression (chaos_test.py 18/18) + CTO sign-off.
77
+ def _build_renderer_prompt(self, pedagogical_rationale: str, step_ids: List[str]) -> str:
78
+ ids_list = ", ".join(f"{{{{{sid}}}}}" for sid in step_ids)
79
+ return f"""You are a Pedagogical Narrator for a Hebrew math tutoring system.
80
+ Your role is to explain the INTUITION behind each solution step, not to compute anything.
81
+ The server has already computed all mathematics with certainty — your job is to tell the story.
82
+
83
+ PEDAGOGICAL RATIONALE:
84
+ {pedagogical_rationale}
85
+
86
+ SERVER PLACEHOLDERS (use EXACTLY as written):
87
+ {ids_list}
88
+
89
+ RULES:
90
+ 1. Hebrew only.
91
+ 2. Explain the intuition and logic behind each step in human, encouraging language.
92
+ Use analogies, real-world stories, or visual metaphors.
93
+ 3. Embed ALL server placeholders exactly: {ids_list}
94
+ 4. FORBIDDEN — do NOT write: any digit, number, variable, operator, fraction, or math symbol.
95
+ Rule: if you feel the urge to write a number or expression, describe its meaning in words instead.
96
+ Use {{{{step_id}}}} to present the server's computed result. No '=', '/', '^', 'sin(x)', 'pi', etc.
97
+ 5. No invented placeholders. Only server-provided IDs above.
98
+
99
+ EXAMPLE (single approved format — "math textbook narrator style"):
100
+ ✅ CORRECT: "כדי למצוא את נקודות החיתוך עם ציר ה-y, נציב אפס בערך ה-x ונקבל: {{{{step_1}}}}."
101
+ ❌ FORBIDDEN: "כדי למצוא את החיתוך נציב x=0 ונקבל y=4." (raw numbers/symbols — blocked by Guardian)
102
+ """
103
+
104
+ async def render(
105
+ self,
106
+ pedagogical_rationale: str,
107
+ signed_steps: List[dict],
108
+ action: Optional[str] = None, # V7.2.5: Planner Enum action for SemanticBank lookup
109
+ ) -> dict:
110
+ """
111
+ V7.2.5 Semantic Composer:
112
+ 1. Try DiversityEngine.compose(action) — deterministic narrative from SemanticBank.
113
+ 2. If no bank entry exists — falls back to LLM #2 (Graceful Degradation).
114
+ Both paths run renderer_guard() + scan_for_math_leakage() before returning.
115
+ """
116
+ step_ids = [s["id"] for s in signed_steps]
117
+
118
+ # ── Path A: Deterministic Semantic Composer ─────────────────────────────
119
+ if action is not None:
120
+ engine = get_diversity_engine()
121
+ composed = engine.compose(action, signed_steps)
122
+ if composed is not None:
123
+ logger.info(f"[RENDERER] 📚 SemanticBank hit for '{action}'. Using deterministic narrative.")
124
+ # Inject server results into {{placeholders}} in composed narrative
125
+ final_text = inject_signed_results(composed, signed_steps)
126
+ # Guard: composed output must still pass safety checks
127
+ if not renderer_guard(composed): # check pre-injection text
128
+ logger.error("[RENDERER] renderer_guard failed on SemanticBank output!")
129
+ return {"success": False, "reason": "SEMANTIC_BANK_GUARD_VIOLATION"}
130
+ if not scan_for_math_leakage(final_text):
131
+ logger.error("[RENDERER] Leakage scan failed on SemanticBank output!")
132
+ return {"success": False, "reason": "SEMANTIC_BANK_LEAKAGE"}
133
+ logger.info("✅ [RENDERER] Semantic Composer output approved by all guards.")
134
+ return {"success": True, "rendered_text": final_text, "source": "semantic_bank"}
135
+
136
+ # ── Path B: LLM #2 Fallback (unknown action or no bank entry) ────────────
137
+ logger.info(f"[RENDERER] No SemanticBank entry for action='{action}'. Using LLM #2 fallback.")
138
+ step_ids = [s["id"] for s in signed_steps]
139
+
140
+ system_prompt = self._build_renderer_prompt(pedagogical_rationale, step_ids)
141
+ user_prompt = (
142
+ f"Write the pedagogical explanation for this solution. "
143
+ f"Use the placeholders: {', '.join(step_ids)}"
144
+ )
145
+
146
+ logger.info(f"[RENDERER] Requesting pedagogical explanation for steps: {step_ids}")
147
+
148
+ try:
149
+ raw_text = await self.llm_gateway.generate_raw(system_prompt, user_prompt)
150
+
151
+ # Bi-directional placeholder guard
152
+ if not validate_placeholders(raw_text, step_ids):
153
+ logger.error("[RENDERER] Placeholder guard FAILED. Failing closed.")
154
+ return {"success": False, "reason": "PLACEHOLDER_GUARD_VIOLATION"}
155
+
156
+ # ── Renderer Guard (V7.2.4) — strict keyword + digit blacklist ────
157
+ # Runs on raw LLM output BEFORE injection to catch trig keywords,
158
+ # individual digits, and operators the LLM "helpfully" wrote itself.
159
+ if not renderer_guard(raw_text):
160
+ logger.error("[RENDERER] renderer_guard FAILED on raw LLM output. Failing closed.")
161
+ telemetry.emit_renderer_leakage("renderer_guard_raw")
162
+ return {"success": False, "reason": "RENDERER_GUARD_VIOLATION"}
163
+
164
+ # Inject actual results from server
165
+ final_text = inject_signed_results(raw_text, signed_steps)
166
+
167
+ # ── Renderer Guardian Scan (V7.2.2/V7.2.3) — whitelist + consecutive chars ─
168
+ if not scan_for_math_leakage(final_text):
169
+ logger.error(
170
+ "[RENDERER GUARDIAN] Math leakage in final injected text! Failing closed."
171
+ )
172
+ telemetry.emit_renderer_leakage("post-injection-leak")
173
+ return {"success": False, "reason": "RENDERER_GUARDIAN_LEAKAGE"}
174
+
175
+ logger.info("[RENDERER] ✅ Both guards passed. Pedagogical explanation approved.")
176
+ return {"success": True, "rendered_text": final_text}
177
+
178
+ except Exception as e:
179
+ logger.error(f"[RENDERER] LLM failure: {e}")
180
+ return {"success": False, "reason": f"LLM_FAILURE: {e}"}
181
+
182
+
183
+ # ==================== V7.2.4: RENDERER GUARD (Strict Blacklist) ====================
184
+
185
+ def renderer_guard(text: str) -> bool:
186
+ """
187
+ V7.2.4 Structural Output Lock — strict blacklist scan on RAW LLM output.
188
+ Runs BEFORE placeholder injection to catch violations at the source.
189
+
190
+ Blocks: individual digits, operator chars, named math functions (sin/cos/tan/sqrt/pi).
191
+ This is stricter than scan_for_math_leakage() which uses a whitelist on the FINAL text.
192
+
193
+ Returns True (safe) / False (fail closed).
194
+ """
195
+ # Remove valid server-provided {{placeholders}} first
196
+ clean = re.sub(r'\{\{.*?\}\}', '', text)
197
+
198
+ # Blacklist pattern: any digit, basic operator, or named math function
199
+ FORBIDDEN_PATTERN = r'[0-9]|[+*/^=]|(?<![a-zA-Z\u0590-\u05FF])(sin|cos|tan|sqrt|pi)(?![a-zA-Z\u0590-\u05FF])'
200
+
201
+ match = re.search(FORBIDDEN_PATTERN, clean)
202
+ if match:
203
+ logger.warning(
204
+ f"[RENDERER_GUARD] Forbidden content in raw LLM output: '{match.group(0)}'. "
205
+ f"Failing closed."
206
+ )
207
+ return False
208
+ return True
209
+
210
+
211
+ # ==================== V7.2: TICKET 5 — UI GATE WHITELIST SCAN ====================
212
+
213
+ def scan_for_math_leakage(rendered_text: str) -> bool:
214
+ """
215
+ V7.2.3 Hardened Whitelist Scan (NOT a blacklist).
216
+ After removing {{...}} placeholders, the remaining text may ONLY contain:
217
+ - Hebrew letters (Unicode block U+0590–U+05FF)
218
+ - English letters (for natural language words)
219
+ - Spaces and basic punctuation (. , ; : ! ? ' " -)
220
+
221
+ Two-pass enforcement:
222
+ Pass 1 — Full whitelist: any forbidden char → REJECT.
223
+ Pass 2 — Consecutive math chars: >2 consecutive math symbols outside {{}}
224
+ catches patterns like '3((9)*(+1))', '^2+4', '/6)' even if
225
+ pass 1 might miss edge-case single chars.
226
+ """
227
+ # Remove all valid server-provided {{placeholders}} first
228
+ clean_text = re.sub(r'\{\{\w+\}\}', '', rendered_text).strip()
229
+
230
+ # V7.2.5 FIX: Remove backtick-wrapped server-signed expressions.
231
+ # inject_signed_results() wraps each signed expression in `backticks`.
232
+ # These are TRUSTED server content (SymPy output) — NOT LLM-generated.
233
+ # They MUST be excluded from the leakage scan to avoid false positives.
234
+ clean_text = re.sub(r'`[^`]+`', '', clean_text).strip()
235
+
236
+ if not clean_text:
237
+ return True # Text was purely placeholders + signed expressions — safe
238
+
239
+ # Pass 1: Full whitelist — Hebrew + English + basic punctuation + typography + backticks ONLY
240
+ ALLOWED_PATTERN = r'^[\u0590-\u05FFa-zA-Z\s.,;:!?\-\"\'\u2014\u2013\u2012\u200f\u200e`]+$'
241
+ whitelist_ok = bool(re.match(ALLOWED_PATTERN, clean_text))
242
+
243
+ if not whitelist_ok:
244
+ # Show all characters that were NOT in the whitelist (sync this regex with ALLOWED_PATTERN)
245
+ violations = re.sub(r'[\u0590-\u05FFa-zA-Z\s.,;:!?\-\"\'\u2014\u2013\u2012\u200f\u200e`]+', '', clean_text)
246
+ logger.warning(f"[UI_GATE] Math leakage detected! Offending chars: '{violations[:50]}'")
247
+ telemetry.emit_renderer_leakage(violations[:50])
248
+ return False
249
+
250
+ # Pass 2: Consecutive math char sequence detection (V7.2.3 hardening)
251
+ # Catches: '3((9', '^2+', '/6)', etc. — >2 non-whitespace math chars in a row
252
+ # IMPORTANT: exclude backtick (`) as it's used for styling, not as a math operator.
253
+ MATH_CHAR_PATTERN = r'[0-9=+\-*/^<>|\\(){}\[\]%@#&~]{3,}'
254
+ consecutive_match = re.search(MATH_CHAR_PATTERN, clean_text)
255
+ if consecutive_match:
256
+ chain = consecutive_match.group(0)
257
+ logger.warning(
258
+ f"[UI_GATE] Consecutive math char sequence detected: '{chain[:50]}'. "
259
+ f"Failing closed (Pass 2 — V7.2.3 hardening)."
260
+ )
261
+ telemetry.emit_renderer_leakage(f"consecutive_chain:{chain[:30]}")
262
+ return False
263
+
264
+ return True
domain/processing_strategy.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ from enum import Enum
2
+
3
+ class ProcessingStrategy(str, Enum):
4
+ SIMPLE_ARITHMETIC = "SIMPLE_ARITHMETIC" # Fast Path
5
+ STRICT_SYMBOLIC = "STRICT_SYMBOLIC" # דורש הוכחה / מבנה סמלי
6
+ HEURISTIC_DEDUCTION = "HEURISTIC_DEDUCTION" # טקסטואלי / גנרי
7
+ CONVERSATIONAL = "CONVERSATIONAL" # שיח חופשי
domain/proposal_engine.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # domain/proposal_engine.py - V7.2 (Deterministic Governed Reasoning Runtime)
2
+ import json
3
+ import logging
4
+ from typing import List
5
+ from enum import Enum
6
+ from pydantic import BaseModel, ValidationError
7
+ from .ontology import get_allowed_concepts
8
+ import domain.telemetry as telemetry
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # ==================== V7.2: STRICT SCHEMA DEFINITIONS ====================
13
+
14
+ class ComputeAction(str, Enum):
15
+ """V7.3: Hardcoded allowed actions. The Planner may NOT invent new ones."""
16
+ # ── Algebraic (V7.2) ──────────────────────────────────────────────────
17
+ SOLVE_EQUATION = "SOLVE_EQUATION"
18
+ SIMPLIFY = "SIMPLIFY"
19
+ FIND_DERIVATIVE = "FIND_DERIVATIVE"
20
+ FIND_INTEGRAL = "FIND_INTEGRAL"
21
+ FACTOR = "FACTOR"
22
+ EXPAND = "EXPAND"
23
+ SUBSTITUTE = "SUBSTITUTE"
24
+ # ── Geometry / Analytic (V7.3) ──────────────────────────────────────
25
+ FIND_AXIS_INTERSECTIONS = "FIND_AXIS_INTERSECTIONS" # הצב x=0 / y=0 ופתור
26
+ CALCULATE_SLOPE_AND_LINE = "CALCULATE_SLOPE_AND_LINE" # שיפוע + משואת ישר
27
+ CALCULATE_DISTANCE = "CALCULATE_DISTANCE" # נוסחת מרחק + בדיקת מיקום
28
+
29
+ class ServerComputeTask(BaseModel):
30
+ action: ComputeAction # Enum enforced: unknown action → ValidationError
31
+ target_step_ref: str # AST Node ID only (e.g. "ast_node_2"), NOT a math expression
32
+
33
+ class PlannerResponse(BaseModel):
34
+ pedagogical_rationale: str
35
+ requires_server_compute: List[ServerComputeTask]
36
+
37
+ class PlannerFormatError(Exception):
38
+ """Raised when the Planner's response fails structural or schema validation."""
39
+ pass
40
+
41
+ MAX_SERVER_STEPS = 8 # DOS Prevention: Planner cannot request more than 8 server tasks
42
+
43
+
44
+ # ==================== V7.2: SAFE JSON EXTRACTION ====================
45
+
46
+ def extract_and_validate_plan(raw_response: str) -> PlannerResponse:
47
+ """
48
+ V7.2: Index-based JSON extraction with PlannerFormatError on any failure.
49
+ Refuses split() to avoid silent IndexError.
50
+ """
51
+ if "<JSON_START>" not in raw_response or "<JSON_END>" not in raw_response:
52
+ raise PlannerFormatError("Missing explicit JSON boundaries <JSON_START>/<JSON_END>.")
53
+
54
+ start = raw_response.index("<JSON_START>") + len("<JSON_START>")
55
+ end = raw_response.index("<JSON_END>")
56
+ raw_json = raw_response[start:end].strip()
57
+
58
+ try:
59
+ plan = PlannerResponse.model_validate_json(raw_json)
60
+
61
+ # Guard: DOS Prevention
62
+ if len(plan.requires_server_compute) > MAX_SERVER_STEPS:
63
+ raise PlannerFormatError(
64
+ f"DOS Guard: Plan requested {len(plan.requires_server_compute)} steps, max is {MAX_SERVER_STEPS}."
65
+ )
66
+
67
+ return plan
68
+ except (ValidationError, json.JSONDecodeError) as e:
69
+ raise PlannerFormatError(f"Validation failed: {e}")
70
+ except PlannerFormatError:
71
+ raise
72
+ except Exception as e:
73
+ raise PlannerFormatError(f"Unexpected extraction error: {e}")
74
+
75
+
76
+ # ==================== V7.2: PROPOSAL ENGINE ====================
77
+
78
+ class ProposalEngine:
79
+ """
80
+ V7.2 Deterministic Governed Reasoning — Pedagogical Planner (LLM #1)
81
+
82
+ The LLM receives only AST metadata (IDs and operations) and returns
83
+ a structured Plan (JSON). It NEVER writes math expressions — only references AST Node IDs.
84
+ """
85
+
86
+ def __init__(self, llm_gateway):
87
+ self.llm_gateway = llm_gateway
88
+
89
+ def _build_system_prompt(self, ast_metadata: dict, prompt_specialization: str, sub_question_text: str = "") -> str:
90
+ """Build the strict Planner system prompt including boundary enforcement."""
91
+ allowed_concepts = get_allowed_concepts(
92
+ ast_metadata.get("detected_operations", ["general"])[0],
93
+ "general_algebra"
94
+ )
95
+ concepts_str = ", ".join(allowed_concepts) if allowed_concepts else "מונחי אלגברה מדויקים"
96
+
97
+ sub_q_line = f"\nSUB-QUESTION TO SOLVE NOW: {sub_question_text}" if sub_question_text else ""
98
+
99
+ return f"""You are a Pedagogical Planner for a math tutoring system.
100
+ Your role is to plan HOW to solve a math problem, NOT to solve it yourself.
101
+
102
+ PROBLEM CONTEXT (AST Metadata):
103
+ {json.dumps(ast_metadata, ensure_ascii=False, indent=2)}{sub_q_line}
104
+
105
+ CRITICAL RULES:
106
+ 1. You MUST wrap your entire JSON response between <JSON_START> and <JSON_END> tags.
107
+ 2. Do NOT write any math expressions or compute anything yourself.
108
+ 3. When referring to AST nodes, use their IDs only (e.g. "ast_node_0").
109
+ 4. Your response MUST conform to this exact JSON schema:
110
+ {{
111
+ "pedagogical_rationale": "<short explanation of the teaching strategy in Hebrew>",
112
+ "requires_server_compute": [
113
+ {{"action": "<ENUM_ACTION>", "target_step_ref": "<ast_node_id>"}},
114
+ ...
115
+ ]
116
+ }}
117
+ 5. Allowed actions (ENUM only): SOLVE_EQUATION, SIMPLIFY, FIND_DERIVATIVE, FIND_INTEGRAL, FACTOR, EXPAND, SUBSTITUTE, FIND_AXIS_INTERSECTIONS, CALCULATE_SLOPE_AND_LINE, CALCULATE_DISTANCE.
118
+ 6. Maximum {MAX_SERVER_STEPS} compute steps.
119
+ 7. Pedagogical Constraint: {prompt_specialization}
120
+ 8. Vocabulary: Prioritize these terms: [{concepts_str}].
121
+ 9. SINGLE-ACTION CONSTRAINT (critical): For complex equations (circles, trigonometry, systems),
122
+ always use SOLVE_EQUATION directly on ast_node_0. DO NOT chain operations that reference
123
+ intermediate result nodes (ast_node_1, ast_node_2, etc.) — those nodes do not exist in
124
+ the server registry. The SymPy engine handles simplification and expansion internally
125
+ as part of SOLVE_EQUATION. Violating this rule causes a server crash.
126
+ 10. CONTEXTUAL ROUTING (V7.3 — critical): Analyze the SUB-QUESTION TO SOLVE NOW carefully.
127
+ Select the action that matches the specific task:
128
+ - "חיתוך", "צירים" → FIND_AXIS_INTERSECTIONS
129
+ - "ישר", "שיפוע", "משואה" → CALCULATE_SLOPE_AND_LINE
130
+ - "מרחק", "נקודה על המעגל" → CALCULATE_DISTANCE
131
+ - General algebra/equation → SOLVE_EQUATION
132
+ You MUST choose the action that solves specifically this sub-question.
133
+ Do NOT use SOLVE_EQUATION when a more specific action applies."""
134
+
135
+
136
+ async def generate_draft_proposal(self, context_obj, prompt_specialization: str, ast_metadata: dict = None) -> dict:
137
+ """
138
+ Asks LLM #1 (Planner) to produce a structured action plan.
139
+ Returns {"success": True, "plan": PlannerResponse} or {"success": False, "reason": ...}.
140
+ """
141
+ if ast_metadata is None:
142
+ ast_metadata = {}
143
+
144
+ system_prompt = self._build_system_prompt(
145
+ ast_metadata, prompt_specialization,
146
+ sub_question_text=getattr(context_obj, 'sub_question_text', '')
147
+ )
148
+ user_prompt = (
149
+ f"Plan the solution for: {context_obj.math_input}\n"
150
+ f"Remember: Output JSON only, wrapped in <JSON_START>...<JSON_END> tags."
151
+ )
152
+
153
+ logger.info(f"[PLANNER] Requesting plan for: {context_obj.math_input}")
154
+
155
+ for attempt in range(2): # Max 1 retry (Full Strategy Re-run)
156
+ try:
157
+ raw_response = await self.llm_gateway.generate_raw(system_prompt, user_prompt)
158
+ plan = extract_and_validate_plan(raw_response)
159
+
160
+ logger.info(
161
+ f"[PLANNER] Valid plan received on attempt {attempt + 1}. "
162
+ f"Actions: {[t.action for t in plan.requires_server_compute]}"
163
+ )
164
+ # Phase 1 Live: Track which Enum actions the Planner chose (drift detection)
165
+ chosen_actions = [task.action.value for task in plan.requires_server_compute]
166
+ telemetry.emit_planner_strategy_distribution(chosen_actions)
167
+ return {"success": True, "plan": plan}
168
+
169
+ except PlannerFormatError as e:
170
+ logger.warning(f"[PLANNER] Attempt {attempt + 1} failed: {e}")
171
+ if attempt == 0:
172
+ # Full Strategy Re-run: inject hard feedback and retry
173
+ user_prompt = (
174
+ f"Your previous response was invalid: {e}\n"
175
+ f"Try again. Plan the solution for: {context_obj.math_input}\n"
176
+ f"You MUST use <JSON_START> and <JSON_END> tags. Output valid JSON only."
177
+ )
178
+ continue
179
+ else:
180
+ logger.error("[PLANNER] Max retries exhausted. Failing closed.")
181
+ return {"success": False, "reason": str(e)}
182
+
183
+ except Exception as e:
184
+ logger.error(f"[PLANNER] LLM failure on attempt {attempt + 1}: {e}")
185
+ if attempt == 0:
186
+ continue
187
+ return {"success": False, "reason": f"LLM_FAILURE: {e}"}
188
+
189
+ return {"success": False, "reason": "MAX_RETRIES_EXHAUSTED"}
domain/risk_engine.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # domain/risk_engine.py
2
+ import logging
3
+ from typing import Dict, Any, List
4
+ from .curriculum_classifier import CurriculumClassifier
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ class CognitiveRiskEngine:
9
+ """
10
+ V6.1 Phase 4: Risk Scoring
11
+ Evaluates the cognitive risk of a proposal based on AST complexity,
12
+ step count, retries, and symbolic stability.
13
+ """
14
+
15
+ WEIGHTS = {
16
+ "ast_complexity": 0.30,
17
+ "reasoning_depth": 0.20,
18
+ "retry_penalty": 0.25,
19
+ "symbolic_instability": 0.25
20
+ }
21
+
22
+ @classmethod
23
+ def calculate_risk_score(cls,
24
+ math_input: str,
25
+ draft_steps: List[Dict[str, Any]],
26
+ retry_count: int,
27
+ validation_errors: int) -> Dict[str, Any]:
28
+ """
29
+ Calculates the Cognitive Risk Score (CRS) between 0.0 and 1.0.
30
+ """
31
+ # 1. AST Complexity (normalized 0-1)
32
+ # We reuse CurriculumClassifier's internal complexity estimation if possible,
33
+ # but here we normalize it against a ceiling of 20.
34
+ raw_complexity = CurriculumClassifier.estimate_complexity(math_input)
35
+ ast_risk = min(raw_complexity / 20.0, 1.0)
36
+
37
+ # 2. Reasoning Depth (normalized 0-1)
38
+ # 10 steps is considered high depth for our MVP
39
+ depth_risk = min(len(draft_steps) / 10.0, 1.0)
40
+
41
+ # 3. Retry Penalty
42
+ # 0 retries = 0.0, 1 retry = 1.0 (since max retry is 1 in V6.1)
43
+ retry_risk = 1.0 if retry_count > 0 else 0.0
44
+
45
+ # 4. Symbolic Instability
46
+ # Based on validation failures during the process
47
+ instability_risk = min(validation_errors / 5.0, 1.0)
48
+
49
+ # Weighted Final Score
50
+ crs = (ast_risk * cls.WEIGHTS["ast_complexity"] +
51
+ depth_risk * cls.WEIGHTS["reasoning_depth"] +
52
+ retry_risk * cls.WEIGHTS["retry_penalty"] +
53
+ instability_risk * cls.WEIGHTS["symbolic_instability"])
54
+
55
+ result = {
56
+ "risk_score": round(crs, 3),
57
+ "features": {
58
+ "ast_risk": round(ast_risk, 3),
59
+ "depth_risk": round(depth_risk, 3),
60
+ "retry_risk": retry_risk,
61
+ "instability_risk": round(instability_risk, 3)
62
+ }
63
+ }
64
+
65
+ logger.info(f"🧠 [RISK_ENGINE] Calculated CRS: {result['risk_score']} (AST: {ast_risk}, Depth: {depth_risk}, Retry: {retry_risk})")
66
+ return result
domain/schemas.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from enum import Enum
2
+ from typing import Optional, Any, Dict
3
+ from pydantic import BaseModel, Field
4
+
5
+ class BuddyState(str, Enum):
6
+ STRATEGY_READY = "STRATEGY_READY"
7
+ SECTION_WORKING = "SECTION_WORKING"
8
+ SECTION_READY = "SECTION_READY"
9
+ COMPLETE = "COMPLETE"
10
+ ERROR = "ERROR"
11
+
12
+ class BuddyEvent(BaseModel):
13
+ question_id: str = Field(..., description="Unique ID for the question session")
14
+ state: BuddyState = Field(..., description="Current state of the micro-agent")
15
+ current_section_id: Optional[str] = Field(None, description="The ID of the section (e.g., 'א', 'ב') currently being processed")
16
+ payload: Dict[str, Any] = Field(default_factory=dict, description="Payload containing state-specific data")
17
+
18
+ class Config:
19
+ use_enum_values = True
domain/semantic_bank.py ADDED
@@ -0,0 +1,366 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # domain/semantic_bank.py — V7.2.5: Semantic Composer
2
+ #
3
+ # The SemanticBank is a governed, deterministic source of pedagogical narratives.
4
+ # Instead of letting LLM #2 generate free text (risky), the NarrativeComposer picks
5
+ # from curated, teacher-grade Hebrew templates and injects server-signed results.
6
+ #
7
+ # CTO directive: text must sound like a real private tutor speaking to a 10th/12th grader.
8
+ # Write at eye level — encouraging, intuitive, NOT like system error messages.
9
+
10
+ import random
11
+ import logging
12
+ from dataclasses import dataclass, field
13
+ from collections import Counter
14
+ from typing import List, Dict, Optional
15
+
16
+ from domain import telemetry
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ DRIFT_ALERT_THRESHOLD = 0.35 # Emit alert when one variant dominates above this fraction
21
+
22
+
23
+ # ─── Data Model ───────────────────────────────────────────────────────────────
24
+
25
+ @dataclass
26
+ class SemanticEntry:
27
+ """
28
+ A single pedagogical concept in the bank.
29
+ Each entry holds multiple variant fragments to ensure narrative diversity.
30
+ """
31
+ concept_tag: str # matches Planner Enum action (e.g. "SOLVE_EQUATION")
32
+ openings: List[str] # 3 opening phrases — how to START the explanation
33
+ bridges: List[str] # 3 logic bridges — HOW to connect the steps
34
+ analogies: List[str] # 2 analogy/metaphor sentences — the "aha!" moments
35
+ closing: str # 1 closing encouragement sentence
36
+
37
+
38
+ # ─── The Bank ─────────────────────────────────────────────────────────────────
39
+
40
+ SEMANTIC_BANK: Dict[str, SemanticEntry] = {
41
+
42
+ "SOLVE_EQUATION": SemanticEntry(
43
+ concept_tag="SOLVE_EQUATION",
44
+ openings=[
45
+ "בואו נפתור את המשוואה צעד אחר צעד, אל תדאג, זה פחות מפחיד משנראה.",
46
+ "כדי לגלות את ערך הנעלם, אנחנו הולכים לבודד אותו משני צידי המשוואה.",
47
+ "נחשוב על המשוואה כמו מאזניים: כל מה שעושים בצד ימין, עושים גם בצד שמאל.",
48
+ ],
49
+ bridges=[
50
+ "אחרי הפעולה הזו, כל מה שנותר הוא:",
51
+ "וכשנפשט את כל מה שקיבלנו, התוצאה מתגלה:",
52
+ "המשוואה 'התנקתה' ועכשיו ברור שהתשובה היא:",
53
+ ],
54
+ analogies=[
55
+ "חשוב על זה כמו חידה — אנחנו מסירים רמזים אחד אחד עד שהנעלם חשוף לגמרי.",
56
+ "בדיוק כמו שמשחקים מחבואים — אנחנו מחפשים את x עד שהוא כבר לא יכול להתחבא.",
57
+ ],
58
+ closing="עשית את זה! זו הדרך הרשמית שבה מתמטיקאים פותרים משוואות.",
59
+ ),
60
+
61
+ "SIMPLIFY": SemanticEntry(
62
+ concept_tag="SIMPLIFY",
63
+ openings=[
64
+ "הביטוי הזה נראה מסובך, אבל אם נסדר אותו — הוא מתקפל לצורה הרבה יותר נקייה.",
65
+ "בפישוט, המטרה היא לכתוב את אותו הדבר — רק בצורה המינימלית ביותר.",
66
+ "כמו לסדר חדר מבולגן — אנחנו אוספים איברים דומים ומסלקים מה שמיותר.",
67
+ ],
68
+ bridges=[
69
+ "אחרי שנאסוף את כל האיברים הדומים יחד, הביטוי הפשוט הוא:",
70
+ "כשמסלקים את כל 'הרעש המתמטי', מה שנשאר הוא:",
71
+ "הצורה הפשוטה והנקייה ביותר של הביטוי:",
72
+ ],
73
+ analogies=[
74
+ "דמיין שיש לך הרבה שטרות של כסף — אתה מחליף אותם לשטר אחד גדול. זה בדיוק מה שאנחנו עושים עם הביטוי.",
75
+ "כמו לקפל מפת ענק — בסוף קיבלת את אותו המידע, רק בגודל שנוח לשאת.",
76
+ ],
77
+ closing="יופי! פישוט זה אחת הכישורים הכי שימושיים במתמטיקה — ועכשיו אתה יודע לעשות את זה.",
78
+ ),
79
+
80
+ "FACTOR": SemanticEntry(
81
+ concept_tag="FACTOR",
82
+ openings=[
83
+ "פירוק לגורמים זה כמו למצוא את 'הבניינים' שמרכיבים את הביטוי שלנו.",
84
+ "במקום לראות ביטוי אחד גדול, אנחנו מחפשים שני ביטויים קטנים שמוכפלים זה בזה.",
85
+ "הטריק בפירוק לגורמים הוא לזהות מה 'מסתתר' בתוך הביטוי ואפשר להוציא החוצה.",
86
+ ],
87
+ bridges=[
88
+ "כשנוציא את הגורם המשותף, נקבל:",
89
+ "אחרי הפירוק, הביטוי נכתב בצורה הכפלית:",
90
+ "הגורמים שמרכיבים את הביטוי הם:",
91
+ ],
92
+ analogies=[
93
+ "פירוק לגורמים הוא כמו לפרק מספר לחלוקה ראשונית — אנחנו מוצאים את ה-DNA המתמטי של הביטוי.",
94
+ "דמיין שאתה מפרק ארגז גדול לקופסאות קטנות — כל קופסא היא גורם. ביחד הן מרכיבות את המקור.",
95
+ ],
96
+ closing="פירוק לגורמים הוא בדיוק מה שמאפשר לנו לפתור משוואות ריבועיות — תכיר את הכלי הזה טוב.",
97
+ ),
98
+
99
+ "EXPAND": SemanticEntry(
100
+ concept_tag="EXPAND",
101
+ openings=[
102
+ "עכשיו נפרוש את הסוגריים — נכפיל כל איבר בתוך הסוגריים עם כל מה שמחוצה להם.",
103
+ "פתיחת סוגריים היא כמו 'לפתוח' מתנה עטופה — מה שמסתתר בפנים יוצא לאור.",
104
+ "נשתמש בחוק הפילוג כדי להרחיב את הביטוי ולראות את כל האיברים בנפרד.",
105
+ ],
106
+ bridges=[
107
+ "לאחר פתיחת הסוגריים ואיסוף האיברים הדומים:",
108
+ "כשמרחיבים הכל ומסדרים:",
109
+ "הביטוי המורחב, עם כל האיברים גלויים:",
110
+ ],
111
+ analogies=[
112
+ "חשוב על זה כמו להכפיל תמחיר — אם קנית שלושה שקים, כל אחד עם תפוחים ובננות, אתה מחשב כמה תפוחים וכמה בננות יש בסך הכל.",
113
+ "פתיחת סוגריים היא כמו לגלגל בצק — לוחצים ומרחיבים עד שהכל שטוח ונראה.",
114
+ ],
115
+ closing="הרחבת הביטוי זו מיומנות בסיסית שתשתמש בה שוב ושוב — וכבר שלטת בה.",
116
+ ),
117
+
118
+ "FIND_DERIVATIVE": SemanticEntry(
119
+ concept_tag="FIND_DERIVATIVE",
120
+ openings=[
121
+ "הנגזרת אומרת לנו כמה מהר הפונקציה עולה או יורדת — היא כמו 'מד המהירות' של הגרף.",
122
+ "כדי למצוא את הנגזרת, אנחנו שואלים: אם x זז קצת קדימה — כמה y משתנה?",
123
+ "נחשב את הנגזרת לפי כללי הגזירה שנלמדו — זה הרבה יותר מהיר מההגדרה הבסיסית.",
124
+ ],
125
+ bridges=[
126
+ "לפי כלל הגזירה ורשימת הנגזרות, קיבלנו:",
127
+ "הנגזרת, שמייצגת את שיפוע המשיק לפונקציה, היא:",
128
+ "קצב השינוי המיידי של הפונקציה הוא:",
129
+ ],
130
+ analogies=[
131
+ "אם הפונקציה היא מסלול נסיעה, הנגזרת היא המד-מהירות — היא אומרת לך כמה מהר אתה נע בכל רגע.",
132
+ "דמיין שאתה מטפס על הר — הנגזרת אומרת לך בכל נקודה עד כמה התלילות. חיובית = עולים, שלילית = יורדים.",
133
+ ],
134
+ closing="הנגזרת פותחת עולם שלם — מניתוח קצוות ועד מכניקה. כל הכבוד על המיומנות הזו.",
135
+ ),
136
+
137
+ "FIND_INTEGRAL": SemanticEntry(
138
+ concept_tag="FIND_INTEGRAL",
139
+ openings=[
140
+ "האינטגרל הוא הפעולה ההפוכה לגזירה — אנחנו שואלים 'מה הפונקציה שהנגזרת שלה היא זו?'.",
141
+ "כדי לחשב את האינטגרל, נשתמש בנוסחאות הבסיסיות ובחוק ה-C, ה-קבוע האינטגרציה.",
142
+ "האינטגרל הלא מסוים נותן לנו משפחה שלמה של פונקציות — שונות זו מזו בקבוע בלבד.",
143
+ ],
144
+ bridges=[
145
+ "אחרי אינטגרציה לפי כלל ההפיכה של הנגזרת:",
146
+ "הפונקציה הפרימיטיבית שמרכיבה את האינטגרל היא:",
147
+ "ביצוע האינטגרציה נותן לנו:",
148
+ ],
149
+ analogies=[
150
+ "אם הנגזרת היא מד-המהירות, האינטגרל הוא מד-המרחק — הוא אוסף את כל השינויים הקטנים לסכום אחד.",
151
+ "חשוב על אינטגרציה כמו לגלגל סרט לאחור — אנחנו 'מחזירים' את הגזירה לנקודת המוצא שלה.",
152
+ ],
153
+ closing="אינטגרציה היא לב חשבון אינפיניטסימלי — ועכשיו יש לך את הכלי לחשב שטחים, נפחים ועוד.",
154
+ ),
155
+
156
+ "SUBSTITUTE": SemanticEntry(
157
+ concept_tag="SUBSTITUTE",
158
+ openings=[
159
+ "כדי לבדוק את הפתרון (או להשלים חישוב), נציב את הערך הידוע ונפשט.",
160
+ "ההצבה היא הדרך שלנו לחבר בין שני חלקי הבעיה — מציבים מה שיודעים ורואים מה יוצא.",
161
+ "נחליף את המשתנה בערך הנתון ונחשב — זה ישאיר לנו ביטוי הרבה יותר פשוט.",
162
+ ],
163
+ bridges=[
164
+ "לאחר ההצבה והפישוט:",
165
+ "כשנציב ונפשט את הביטוי שקיבלנו:",
166
+ "ערך ההצבה מניב:",
167
+ ],
168
+ analogies=[
169
+ "הצבה היא כמו למלא שם בטופס — אנחנו מחליפים את 'הנעלם' בערך הממשי שמצאנו.",
170
+ "דמיין מתכון שאומר 'ספל סוכר' — ברגע שאתה יודע כמה גדול הספל, אתה יכול לחשב כמות מדויקת.",
171
+ ],
172
+ closing="מצוין! ההצבה היא גם דרך מצוינת לבדוק שהפתרון שמצאת הוא נכון.",
173
+ ),
174
+
175
+ # ── V7.3: Geometry / Analytic Entries ────────────────────────────────────
176
+
177
+ "FIND_AXIS_INTERSECTIONS": SemanticEntry(
178
+ concept_tag="FIND_AXIS_INTERSECTIONS",
179
+ openings=[
180
+ "כדי לגלות איפה המעגל פוגש את הצירים, נשאל שאלה פשוטה: מה קורה כשנקודה נמצאת ממש על ציר ה-x? ה-y שלה שווה לאפס! ולהפך.",
181
+ "נקודות חיתוך עם הצירים הן הנקודות שבהן המעגל חוצה את 'הכבישים' של מערכת הצירים.",
182
+ "הדרך לאתר את נקודות החיתוך עם הצירים היא להציב אפס: פעם עבור x ופעם עבור y, ולראות מה הפתרון שיוצא.",
183
+ ],
184
+ bridges=[
185
+ "כשנציב ונפתור, הנקודות שמצאנו הן:",
186
+ "לאחר ההצבה וקבלת הפתרונות, נקודות החיתוך עם הצירים הן:",
187
+ "הפתרונות שקיבלנו מציינים את הנקודות שבהן המעגל חוצה את הצירים:",
188
+ ],
189
+ analogies=[
190
+ "חשוב על מעגל כמו כדור שמתגלגל על רצפה — נקודות החיתוך עם ציר ה-x הן בדיוק המקומות שהכדור נוגע ברצפה.",
191
+ "דמיין שאתה מסתכל על מפה ומחפש איפה כביש ראשי חוצה את הנהר — זה בדיוק מה שאנחנו עושים עם המעגל והצירים.",
192
+ ],
193
+ closing="מצאנו את כל נקודות המגע של המעגל עם מערכת הצירים — עבודה מדויקת!",
194
+ ),
195
+
196
+ "CALCULATE_SLOPE_AND_LINE": SemanticEntry(
197
+ concept_tag="CALCULATE_SLOPE_AND_LINE",
198
+ openings=[
199
+ "כדי למצוא את משוואת הישר העובר דרך שתי נקודות, נחשב קודם את השיפוע — כמה 'תלול' הישר.",
200
+ "שיפוע הישר הוא היחס בין השינוי בגובה לשינוי בהיקף: עלייה חלקי הזזה אופקית.",
201
+ "הישר שמחבר שתי נקודות מוגדר לחלוטין על ידי השיפוע שלו ונקודת מעבר אחת.",
202
+ ],
203
+ bridges=[
204
+ "לאחר חישוב השיפוע, משוואת הישר היא:",
205
+ "כשנוסיף את השיפוע שמצאנו לנוסחת הישר, נקבל:",
206
+ "הישר שמחבר מרכז המעגל לראשית הצירים הוא:",
207
+ ],
208
+ analogies=[
209
+ "שיפוע הוא כמו מדרגות — אם עולים ארבע קומות לכל שלושה צעדים קדימה, השיפוע הוא ארבעה חלקי שלושה.",
210
+ "דמיין ישר כמו דרך בין שתי ערים — השיפוע הוא כמה מטרים עולים לכל קילומטר שנוסעים.",
211
+ ],
212
+ closing="קיבלנו את משוואת הישר בצורה המפורשת — קו ישר שעובר בדיוק דרך שתי הנקודות הנתונות.",
213
+ ),
214
+
215
+ "CALCULATE_DISTANCE": SemanticEntry(
216
+ concept_tag="CALCULATE_DISTANCE",
217
+ openings=[
218
+ "כדי לבדוק האם נקודה נמצאת על המעגל, נמדוד את המרחק בינה לבין מרכז המעגל ונשווה לרדיוס.",
219
+ "נוסחת המרחק בין שתי נקודות היא שורש של סכום ריבועי ההפרשים — בדיוק כמו משפט פיתגורס.",
220
+ "השאלה פשוטה: האם הנקודה נמצאת בדיוק ברדיוס מהמרכז, קרוב יותר, או רחוק יותר?",
221
+ ],
222
+ bridges=[
223
+ "לאחר חישוב המרחק, הממצא הוא:",
224
+ "כשמשווים את המרחק שמצאנו לרדיוס המעגל, מתברר ש:",
225
+ "תוצאת חישוב ה��רחק מול הרדיוס:",
226
+ ],
227
+ analogies=[
228
+ "דמיין מדוזה עגולה — כל נקודה על גבה נמצאת בדיוק באותו מרחק מהמרכז. אם נקודה קרובה יותר — היא בתוכה. רחוקה יותר — מחוצה לה.",
229
+ "מרחק שתי נקודות זה כמו למדוד עם סרגל בין שתי ערים על מפה — פיתגורס עושה את העבודה.",
230
+ ],
231
+ closing="בדקנו מדויק: המרחק חושב, הושווה לרדיוס, ומיקום הנקודה נקבע בוודאות מתמטית.",
232
+ ),
233
+
234
+ }
235
+
236
+
237
+ # ─── Diversity Engine ──────────────────────────────────────────────────────────
238
+
239
+ class DiversityEngine:
240
+ """
241
+ Selects narrative variants from the SemanticBank with diversity enforcement.
242
+
243
+ Tracks selection history per concept to detect "narrative laziness" (Drift):
244
+ if one variant is chosen more than DRIFT_ALERT_THRESHOLD fraction of the time,
245
+ emit a PEDAGOGICAL_DRIFT telemetry alert.
246
+ """
247
+
248
+ def __init__(self):
249
+ # Counter per concept_tag: tracks how many times each (variant_type, index) was picked
250
+ self._history: Dict[str, Counter] = {}
251
+
252
+ def _record(self, concept_tag: str, variant_key: str):
253
+ if concept_tag not in self._history:
254
+ self._history[concept_tag] = Counter()
255
+ self._history[concept_tag][variant_key] += 1
256
+
257
+ def drift_score(self, concept_tag: str) -> float:
258
+ """
259
+ Returns the dominance fraction of the most-selected variant for this concept.
260
+ Score = max_count / total_count.
261
+ 0.0 = perfectly uniform, 1.0 = always the same variant.
262
+ """
263
+ if concept_tag not in self._history or not self._history[concept_tag]:
264
+ return 0.0
265
+ counts = self._history[concept_tag]
266
+ total = sum(counts.values())
267
+ if total == 0:
268
+ return 0.0
269
+ return max(counts.values()) / total
270
+
271
+ def _pick(self, concept_tag: str, variant_type: str, options: List[str]) -> str:
272
+ """
273
+ Weighted random selection that avoids the most recently over-used variants.
274
+ Falls back to uniform random if history is thin (< 5 picks).
275
+ """
276
+ n = len(options)
277
+ history_key_prefix = f"{variant_type}:"
278
+ if concept_tag not in self._history or sum(self._history[concept_tag].values()) < 5:
279
+ # Not enough history — uniform random
280
+ idx = random.randrange(n)
281
+ else:
282
+ counts = self._history[concept_tag]
283
+ # Weight = inverse of how often we've picked this index
284
+ weights = []
285
+ for i in range(n):
286
+ pick_count = counts.get(f"{history_key_prefix}{i}", 0)
287
+ weights.append(1.0 / (1 + pick_count))
288
+ # Weighted choice
289
+ total_w = sum(weights)
290
+ r = random.uniform(0, total_w)
291
+ cumulative = 0.0
292
+ idx = n - 1 # fallback
293
+ for i, w in enumerate(weights):
294
+ cumulative += w
295
+ if r <= cumulative:
296
+ idx = i
297
+ break
298
+
299
+ key = f"{history_key_prefix}{idx}"
300
+ self._record(concept_tag, key)
301
+
302
+ # Check drift and emit telemetry
303
+ score = self.drift_score(concept_tag)
304
+ if score > DRIFT_ALERT_THRESHOLD:
305
+ logger.warning(
306
+ f"[DRIFT_MONITOR] Pedagogical drift detected for '{concept_tag}': "
307
+ f"drift_score={score:.2f} > threshold={DRIFT_ALERT_THRESHOLD}. "
308
+ f"Selection history: {dict(self._history[concept_tag])}"
309
+ )
310
+ telemetry.emit_pedagogical_drift(concept_tag, score)
311
+
312
+ return options[idx]
313
+
314
+ def compose(self, action: str, signed_steps: list) -> str:
315
+ """
316
+ Main entry point: builds a full Hebrew pedagogical narrative for the given action.
317
+ Inserts {{step_id}} placeholders where the server results will be shown.
318
+
319
+ Returns a string with {{placeholder}} notation (not yet injected).
320
+ """
321
+ entry = SEMANTIC_BANK.get(action)
322
+ if entry is None:
323
+ logger.warning(
324
+ f"[SEMANTIC_BANK] No entry for action '{action}'. "
325
+ f"Falling back to LLM #2 renderer."
326
+ )
327
+ return None # Signal to caller: use LLM fallback
328
+
329
+ concept_tag = entry.concept_tag
330
+
331
+ # Pick diverse variants
332
+ opening = self._pick(concept_tag, "opening", entry.openings)
333
+ bridge = self._pick(concept_tag, "bridge", entry.bridges)
334
+ analogy = self._pick(concept_tag, "analogy", entry.analogies)
335
+ closing = entry.closing # Always the same (one closing per concept by design)
336
+
337
+ # Build placeholder string from signed steps
338
+ if not signed_steps:
339
+ placeholder_str = ""
340
+ elif len(signed_steps) == 1:
341
+ placeholder_str = f"{{{{{signed_steps[0]['id']}}}}}"
342
+ else:
343
+ parts = [f"{{{{{s['id']}}}}}" for s in signed_steps]
344
+ placeholder_str = " ← ".join(parts)
345
+
346
+ # Compose the narrative (V7.2.5: no emojis/em-dashes — must pass scan_for_math_leakage)
347
+ narrative = (
348
+ f"{opening}\n\n"
349
+ f"{analogy}\n\n"
350
+ f"{bridge} {placeholder_str}\n\n"
351
+ f"{closing}"
352
+ )
353
+
354
+ logger.info(
355
+ f"[SEMANTIC_BANK] Composed narrative for '{action}' | "
356
+ f"drift_score={self.drift_score(concept_tag):.2f}"
357
+ )
358
+ return narrative
359
+
360
+
361
+ # Module-level singleton — shared across all requests in the process
362
+ _engine = DiversityEngine()
363
+
364
+ def get_diversity_engine() -> DiversityEngine:
365
+ """Returns the process-level DiversityEngine singleton."""
366
+ return _engine
domain/step_types.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # domain/step_types.py - V7.4 (TYPED DOMAIN CONTRACTS)
2
+ """
3
+ V7.4: Typed SignedStep — Domain-Aware Validation Contract.
4
+
5
+ Replaces the raw dict `{"id", "hash", "expression"}` with a typed dataclass
6
+ that carries a StepType so the ConsistencyGate validates by semantic domain,
7
+ not by string shape.
8
+
9
+ CTO directive:
10
+ - expression → display string only (for renderer injection)
11
+ - payload → semantic truth (list[str] for geometry, str for algebraic)
12
+ - step_type → routing key for ConsistencyGate dispatch
13
+
14
+ Backwards compatibility: SignedStep emulates a dict fully so ALL existing
15
+ dict-access code (step["id"], step.get("hash"), "id" in step, step.items())
16
+ in pedagogical_renderer.py / semantic_bank.py continues to work unchanged.
17
+ """
18
+ from enum import Enum
19
+ from dataclasses import dataclass, asdict
20
+ from typing import Any, Optional
21
+
22
+
23
+ class StepType(Enum):
24
+ ALGEBRAIC = "algebraic" # SymPy-parseable expression — validated with sympify
25
+ GEOMETRY = "geometry" # Points, distances, labels — validated structurally
26
+ NUMERIC = "numeric" # Pure numeric results — future use
27
+
28
+
29
+ @dataclass
30
+ class SignedStep:
31
+ """
32
+ V7.4: Typed, SHA256-signed computation step.
33
+
34
+ Fields:
35
+ id — step identifier used as {{id}} placeholder in renderer
36
+ expression — human-readable display string (injected into pedagogy text)
37
+ payload — semantic truth: list[str] for geometry, str for algebraic
38
+ step_type — StepType enum — routes ConsistencyGate validation
39
+ hash — SHA256 digest seeded with canonical form + problem_id + step_id
40
+ """
41
+ id: str
42
+ expression: str
43
+ payload: Any
44
+ step_type: StepType
45
+ hash: str
46
+
47
+ # ── Full dict emulation (CTO requirement) ─────────────────────────────────
48
+ # Allows ALL existing code using step["key"], step.get(...), "key" in step,
49
+ # step.items() to work without any changes.
50
+
51
+ _FIELDS = ("id", "expression", "payload", "step_type", "hash")
52
+
53
+ def __getitem__(self, key: str):
54
+ if key not in self._FIELDS:
55
+ raise KeyError(key)
56
+ return getattr(self, key)
57
+
58
+ def get(self, key: str, default=None):
59
+ try:
60
+ return self[key]
61
+ except KeyError:
62
+ return default
63
+
64
+ def keys(self):
65
+ return list(self._FIELDS)
66
+
67
+ def items(self):
68
+ return [(k, self[k]) for k in self._FIELDS]
69
+
70
+ def __contains__(self, key: object) -> bool:
71
+ return key in self._FIELDS
domain/telemetry.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # domain/telemetry.py - V7.2 Observability Layer
2
+ """
3
+ BuddyMath V7.2 Runtime Telemetry
4
+ Emits structured log-based metrics compatible with Datadog/Grafana log pipelines.
5
+
6
+ All metrics are emitted as structured JSON log lines on the logger named "buddymath.metrics".
7
+ DevOps should configure a log-based metric parser for lines containing "METRIC_EVENT".
8
+
9
+ Metric naming convention: <component>.<event_name>
10
+ """
11
+ import logging
12
+ import json
13
+ import time
14
+ from datetime import datetime, timezone
15
+ from typing import Optional
16
+
17
+ metrics_logger = logging.getLogger("buddymath.metrics")
18
+
19
+ # ==================== METRIC KEY CONSTANTS ====================
20
+ # Use these constants everywhere to prevent typos and enable grep-ability.
21
+
22
+ class M:
23
+ """V7.2 Metric Key Registry"""
24
+
25
+ # Core runtime outcome (feeds the Pie Chart)
26
+ RUNTIME_OUTCOME = "runtime.outcome" # values: success | hint_mode | planner_retry | leakage_fail | placeholder_fail | crs_block
27
+
28
+ # Fail Closed Rate (derived from RUNTIME_OUTCOME != success)
29
+ FAIL_CLOSED = "fail_closed" # emitted on any non-success outcome
30
+
31
+ # Planner (LLM #1)
32
+ PLANNER_RETRY = "planner.retry.count" # emitted on each retry attempt
33
+ PLANNER_ENUM_VIOLATION = "planner.enum_violation" # emitted when ComputeAction enum is violated
34
+ PLANNER_JSON_ERROR = "planner.json_error" # emitted on JSON parse / boundary failure
35
+ PLANNER_STRATEGY_DIST = "planner.strategy_distribution" # which Enum actions are chosen (drift detection)
36
+
37
+ # Renderer (LLM #2)
38
+ RENDERER_LEAKAGE_FAIL = "renderer.leakage_fail" # Whitelist scan failed
39
+ RENDERER_PLACEHOLDER_FAIL = "renderer.placeholder_violation" # Missing or invented placeholder
40
+ RENDERER_LEAKAGE_CHARS = "renderer.leakage_chars" # Offending chars (for slicing)
41
+
42
+ # Solver / Server Determinism
43
+ SOLVER_EXECUTION_MS = "solver.execution_time_ms" # Math engine wall-clock time
44
+ SIGNATURE_HASH_COLLISION = "signature.hash_collision" # MUST always be 0
45
+
46
+ # CRS Pre-Flight
47
+ CRS_BLOCK = "crs.preflight_block" # Emitted when CRS > 0.7 blocks LLM call
48
+ CRS_VALUE = "crs.value" # Raw CRS score for histogram
49
+
50
+ # Security
51
+ SUSPICIOUS_INPUT = "suspicious.input_pattern" # Potential prompt injection detected
52
+
53
+ # Pedagogical Diversity (V7.2.5)
54
+ PEDAGOGICAL_DRIFT = "pedagogical.drift_score" # Narrative laziness: > 0.35 = same template always chosen
55
+
56
+
57
+ # ==================== EMITTER ====================
58
+
59
+ def emit(metric: str, value, tags: Optional[dict] = None):
60
+ """
61
+ Emit a single metric event as a structured JSON log line.
62
+ Datadog/Grafana log-based metric pipelines should parse lines with 'METRIC_EVENT'.
63
+
64
+ Format:
65
+ {"event": "METRIC_EVENT", "metric": "<name>", "value": <v>, "tags": {...}, "timestamp": "..."}
66
+ """
67
+ payload = {
68
+ "event": "METRIC_EVENT",
69
+ "metric": metric,
70
+ "value": value,
71
+ "tags": tags or {},
72
+ "timestamp": datetime.now(timezone.utc).isoformat()
73
+ }
74
+ metrics_logger.info(json.dumps(payload, ensure_ascii=False))
75
+
76
+
77
+ def emit_runtime_outcome(outcome: str, problem_id: str = "unknown", grade: str = "unknown"):
78
+ """Feeds the main Pie Chart and Fail Closed Rate gauge."""
79
+ emit(M.RUNTIME_OUTCOME, outcome, {"problem_id": problem_id, "grade": grade})
80
+ if outcome != "success":
81
+ emit(M.FAIL_CLOSED, 1, {"reason": outcome})
82
+
83
+
84
+ def emit_planner_retry(attempt: int, reason: str):
85
+ emit(M.PLANNER_RETRY, attempt, {"reason": reason})
86
+
87
+
88
+ def emit_planner_error(error_type: str, details: str = ""):
89
+ """error_type: 'enum_violation' | 'json_error'"""
90
+ key = M.PLANNER_ENUM_VIOLATION if error_type == "enum_violation" else M.PLANNER_JSON_ERROR
91
+ emit(key, 1, {"details": details[:120]})
92
+
93
+
94
+ def emit_planner_strategy_distribution(actions: list):
95
+ """
96
+ Phase 1 Live: Emit one metric event per chosen Enum action.
97
+ Feeds the planner.strategy_distribution dashboard panel.
98
+ A sudden shift in distribution signals Model Drift.
99
+ Example: [SOLVE_EQUATION, SIMPLIFY, SOLVE_EQUATION] → 3 events
100
+ """
101
+ for action in actions:
102
+ emit(M.PLANNER_STRATEGY_DIST, 1, {"action": str(action)})
103
+
104
+
105
+ def emit_renderer_leakage(offending_chars: str):
106
+ emit(M.RENDERER_LEAKAGE_FAIL, 1, {"offending_chars": offending_chars[:50]})
107
+ emit(M.RENDERER_LEAKAGE_CHARS, offending_chars[:50])
108
+
109
+
110
+ def emit_renderer_placeholder_violation(violation_type: str, missing_id: str = ""):
111
+ """violation_type: 'missing' | 'invented'"""
112
+ emit(M.RENDERER_PLACEHOLDER_FAIL, 1, {"type": violation_type, "id": missing_id})
113
+
114
+
115
+ def emit_solver_timing(start_time: float):
116
+ """Call with time.time() snapshot taken BEFORE solver runs."""
117
+ elapsed_ms = round((time.time() - start_time) * 1000, 2)
118
+ emit(M.SOLVER_EXECUTION_MS, elapsed_ms)
119
+ return elapsed_ms
120
+
121
+
122
+ def emit_hash_collision(step_id: str, problem_id: str):
123
+ """
124
+ CRITICAL: This MUST never be emitted in a correct system.
125
+ If it fires, it means two different expressions produced the same hash → P0 alert.
126
+ """
127
+ metrics_logger.critical(
128
+ json.dumps({
129
+ "event": "METRIC_EVENT",
130
+ "metric": M.SIGNATURE_HASH_COLLISION,
131
+ "value": 1,
132
+ "tags": {"step_id": step_id, "problem_id": problem_id},
133
+ "timestamp": datetime.now(timezone.utc).isoformat(),
134
+ "severity": "P0_CRITICAL"
135
+ })
136
+ )
137
+
138
+
139
+ def emit_crs_block(crs_value: float, problem_id: str = "unknown"):
140
+ emit(M.CRS_BLOCK, 1, {"crs": crs_value, "problem_id": problem_id})
141
+ emit(M.CRS_VALUE, crs_value)
142
+
143
+
144
+ def emit_crs_value(crs_value: float):
145
+ emit(M.CRS_VALUE, crs_value)
146
+
147
+
148
+ def emit_suspicious_input(pattern: str, problem_id: str = "unknown"):
149
+ emit(M.SUSPICIOUS_INPUT, 1, {"pattern": pattern[:80], "problem_id": problem_id})
150
+
151
+
152
+ def emit_pedagogical_drift(concept_tag: str, drift_score: float):
153
+ """
154
+ V7.2.5: Emitted when DiversityEngine detects narrative laziness.
155
+ drift_score > 0.35 means one template variant is dominating selection.
156
+ Alert DevOps: Renderer is repeating the same pedagogical phrasing — student experience degrades.
157
+ """
158
+ emit(M.PEDAGOGICAL_DRIFT, round(drift_score, 3), {"concept": concept_tag})
159
+
160
+
161
+ # ==================== TIMER CONTEXT MANAGER ====================
162
+
163
+ class SolverTimer:
164
+ """
165
+ Context manager for measuring Math Engine execution time.
166
+ Usage:
167
+ with SolverTimer() as t:
168
+ result = sympy_solve(...)
169
+ print(t.elapsed_ms)
170
+ """
171
+ def __enter__(self):
172
+ self._start = time.time()
173
+ return self
174
+
175
+ def __exit__(self, *args):
176
+ self.elapsed_ms = round((time.time() - self._start) * 1000, 2)
177
+ emit(M.SOLVER_EXECUTION_MS, self.elapsed_ms)
domain/validator.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # domain/validator.py - V7.4 (DOMAIN-AWARE CONSISTENCY GATE)
2
+ """
3
+ V7.4 ConsistencyGate: Domain-Aware Validation.
4
+
5
+ Validates by StepType — NOT by string shape.
6
+
7
+ ALGEBRAIC → sympy.sympify() (existing behaviour, unchanged)
8
+ GEOMETRY → structural validation of payload (list[str])
9
+ NUMERIC → {future expansion point}
10
+
11
+ CTO directive:
12
+ - Geometry steps must NEVER reach sympify (Category Error prevention)
13
+ - Geometry gets its own structural validator, not a "skip blind"
14
+ - expression is display-only — validation uses payload for geometry
15
+ """
16
+ import sympy
17
+ import logging
18
+ from typing import List, Tuple
19
+ from domain.step_types import StepType, SignedStep
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class ConsistencyGate:
25
+ """
26
+ V7.4 Wall 3: Post-Execution Domain-Aware Consistency Gate.
27
+
28
+ Receives the list of SignedStep objects from the Deterministic Solver and
29
+ verifies structural and logical integrity before passing to the Renderer.
30
+
31
+ Checks performed (all domains):
32
+ 1. Non-empty: at least one signed step exists.
33
+ 2. Hash integrity: every step has a non-empty SHA256 hash.
34
+ 3a. ALGEBRAIC: expression is SymPy-parseable (sympy.sympify).
35
+ 3b. GEOMETRY: payload is a non-empty list[str] (structural validation).
36
+ """
37
+
38
+ @staticmethod
39
+ def _validate_algebraic(step: SignedStep) -> Tuple[bool, str]:
40
+ """
41
+ Validates an ALGEBRAIC step by attempting sympy.sympify on each
42
+ sub-expression (split on " OR " for multi-solution results).
43
+ """
44
+ expr_str = step.expression
45
+ if not expr_str:
46
+ logger.error(f"[CONSISTENCY_GATE] Step '{step.id}' has an empty expression.")
47
+ return False, f"EMPTY_EXPRESSION:{step.id}"
48
+
49
+ parts = [p.strip() for p in expr_str.split(" OR ")]
50
+ for part in parts:
51
+ try:
52
+ sympy.sympify(part)
53
+ except Exception as e:
54
+ logger.error(
55
+ f"[CONSISTENCY_GATE] Step '{step.id}' expression not parseable: "
56
+ f"'{part}' — {e}"
57
+ )
58
+ return False, f"UNPARSEABLE_EXPRESSION:{step.id}"
59
+ return True, ""
60
+
61
+ @staticmethod
62
+ def _validate_geometry(step: SignedStep) -> Tuple[bool, str]:
63
+ """
64
+ Validates a GEOMETRY step structurally via payload.
65
+
66
+ Geometry output (points, distances, labels) is NOT SymPy-parseable.
67
+ We verify:
68
+ - payload is a non-empty list
69
+ - every element is a string
70
+ Hash integrity (checked earlier) is the cryptographic guarantee.
71
+ """
72
+ payload = step.payload
73
+ if not isinstance(payload, list) or len(payload) == 0:
74
+ logger.error(
75
+ f"[CONSISTENCY_GATE] Geometry step '{step.id}' has empty or non-list payload."
76
+ )
77
+ return False, f"GEOMETRY_EMPTY_PAYLOAD:{step.id}"
78
+ if not all(isinstance(p, str) for p in payload):
79
+ logger.error(
80
+ f"[CONSISTENCY_GATE] Geometry step '{step.id}' payload contains non-string items."
81
+ )
82
+ return False, f"GEOMETRY_INVALID_PAYLOAD_TYPE:{step.id}"
83
+ logger.info(
84
+ f"[CONSISTENCY_GATE] Geometry step '{step.id}' passed structural validation "
85
+ f"({len(payload)} point(s))."
86
+ )
87
+ return True, ""
88
+
89
+ @staticmethod
90
+ def validate(
91
+ signed_steps: List,
92
+ ast_registry: dict,
93
+ problem_id: str
94
+ ) -> Tuple[bool, str]:
95
+ """
96
+ Returns (True, "") if all checks pass.
97
+ Returns (False, reason) if any check fails → caller must Fail Closed.
98
+
99
+ Accepts both SignedStep objects and legacy dicts for backwards compatibility
100
+ during any partial migration.
101
+ """
102
+ # Check 1: Non-empty output
103
+ if not signed_steps:
104
+ logger.error("[CONSISTENCY_GATE] No signed steps produced by solver.")
105
+ return False, "EMPTY_SOLVER_OUTPUT"
106
+
107
+ for step in signed_steps:
108
+ step_id = step.get("id", "?") if hasattr(step, "get") else step.get("id", "?")
109
+
110
+ # Check 2: Hash presence
111
+ h = step.get("hash", "") if hasattr(step, "get") else step.get("hash", "")
112
+ if not h or len(h) < 10:
113
+ logger.error(f"[CONSISTENCY_GATE] Step '{step_id}' is missing a valid SHA256 hash.")
114
+ return False, f"MISSING_HASH:{step_id}"
115
+
116
+ # Check 3: Domain-aware expression / payload validation
117
+ step_type = step.get("step_type") if hasattr(step, "get") else None
118
+
119
+ if step_type == StepType.GEOMETRY:
120
+ ok, reason = ConsistencyGate._validate_geometry(step)
121
+ if not ok:
122
+ return False, reason
123
+
124
+ elif step_type == StepType.ALGEBRAIC or step_type is None:
125
+ # None = legacy dict without step_type → treat as ALGEBRAIC (backwards compat)
126
+ expr_str = step.get("expression", "")
127
+ if not expr_str:
128
+ logger.error(f"[CONSISTENCY_GATE] Step '{step_id}' has an empty expression.")
129
+ return False, f"EMPTY_EXPRESSION:{step_id}"
130
+ parts = [p.strip() for p in expr_str.split(" OR ")]
131
+ for part in parts:
132
+ try:
133
+ sympy.sympify(part)
134
+ except Exception as e:
135
+ logger.error(
136
+ f"[CONSISTENCY_GATE] Step '{step_id}' expression not parseable: "
137
+ f"'{part}' — {e}"
138
+ )
139
+ return False, f"UNPARSEABLE_EXPRESSION:{step_id}"
140
+
141
+ # StepType.NUMERIC → future expansion point
142
+ # Other unknown types → pass through (hash check is the integrity guarantee)
143
+
144
+ logger.info(
145
+ f"[CONSISTENCY_GATE] ✅ {len(signed_steps)} signed steps passed all checks "
146
+ f"for problem '{problem_id}'."
147
+ )
148
+ return True, ""
explanation_math_firewall.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # explanation_math_firewall.py - V4.2 (Behavioral Firewall)
2
+ # Prevents semantic leaks of forbidden mathematical concepts.
3
+
4
+ import re
5
+ import logging
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ # Concepts that are often "shadow paths" or over-qualified for lower grades
10
+ FORBIDDEN_CONCEPTS = [
11
+ r"נגזרת", r"נגזור", r"גזירה", r"קיצון", r"מקסימום", r"מינימום",
12
+ r"אינטגרל", r"אינטגרציה", r"הפרש", r"מערכת", r"נעלמים", r"שני נעלמים",
13
+ r"פיתגורס", r"שורשים", r"מכנה משותף" # Add more as needed
14
+ ]
15
+
16
+ # Symbols that indicate over-qualified math
17
+ FORBIDDEN_SYMBOLS = [
18
+ r"'", r"\\'", r"integral", r"\\int", r"\\Sigma", r"\\Delta"
19
+ ]
20
+
21
+ def scan_explanation(text: str, proof_graph) -> tuple[bool, str]:
22
+ """
23
+ Scans explanation text for concepts NOT present in the ProofGraph.
24
+ Returns (is_safe, violation_reason).
25
+ """
26
+ if not text:
27
+ return True, ""
28
+
29
+ # 1. Check for Hebrew forbidden concepts
30
+ for pattern in FORBIDDEN_CONCEPTS:
31
+ if re.search(pattern, text):
32
+ # Check if this concept is "allowed" because it's in the ProofGraph
33
+ # (e.g., if we actually used a Derivative, it's fine to say 'נגזרת')
34
+ is_in_proof = False
35
+ if proof_graph:
36
+ for step in proof_graph.steps:
37
+ if step.operator_used and pattern in step.logic_description:
38
+ is_in_proof = True
39
+ break
40
+
41
+ if not is_in_proof:
42
+ logger.warning(f"🛡️ [FIREWALL] Semantic violation detected: '{pattern}'")
43
+ return False, f"Concept '{pattern}' found in text but not in ProofGraph."
44
+
45
+ # 2. Check for forbidden mathematical symbols
46
+ for symbol in FORBIDDEN_SYMBOLS:
47
+ if symbol in text:
48
+ # Similar logic: check if the symbol appears in math_content of proof_graph
49
+ is_in_proof = False
50
+ if proof_graph:
51
+ for step in proof_graph.steps:
52
+ if symbol in str(step.math_content):
53
+ is_in_proof = True
54
+ break
55
+
56
+ if not is_in_proof:
57
+ logger.warning(f"🛡️ [FIREWALL] Symbol violation detected: '{symbol}'")
58
+ return False, f"Symbol '{symbol}' found in text but not in ProofGraph."
59
+
60
+ return True, ""
find_models.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from google import genai
2
+
3
+ client = genai.Client(api_key="YOUR_GEMINI_API_KEY_HERE")
4
+
5
+ print("מחפש מודלי Pro זמינים למפתח שלך...")
6
+ for model in client.models.list():
7
+ if "pro" in model.name:
8
+ print(model.name)
firebase_manager.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import os
3
+ import firebase_admin
4
+ from firebase_admin import credentials, storage
5
+
6
+ # Initialize logging
7
+ logger = logging.getLogger("BIT-LOG")
8
+
9
+ class FirebaseManager:
10
+ """
11
+ V261.17: Manages Firebase Storage uploads for resilient audio playback.
12
+ Replaces local static file serving which causes timeouts and 404s.
13
+ """
14
+
15
+ _instance = None
16
+ _bucket = None
17
+
18
+ def __new__(cls):
19
+ if cls._instance is None:
20
+ cls._instance = super(FirebaseManager, cls).__new__(cls)
21
+ cls._instance._initialize()
22
+ return cls._instance
23
+
24
+ def _initialize(self):
25
+ """Initialize Firebase Admin SDK with service account."""
26
+ try:
27
+ from config import FIREBASE_CREDENTIALS_PATH, STORAGE_BUCKET, IS_PRODUCTION
28
+
29
+ cred_path = FIREBASE_CREDENTIALS_PATH
30
+
31
+ if not os.path.exists(cred_path):
32
+ logger.warning(f"MISSING: [FIREBASE] Missing credentials at {cred_path}. Audio/Storage will fail!")
33
+ return
34
+
35
+ if not firebase_admin._apps:
36
+ cred = credentials.Certificate(cred_path)
37
+ firebase_admin.initialize_app(cred, {
38
+ 'storageBucket': STORAGE_BUCKET
39
+ })
40
+ logger.info(f"SUCCESS: [FIREBASE] Initialized successfully for {'PROD' if IS_PRODUCTION else 'DEV'}.")
41
+
42
+ self._bucket = storage.bucket()
43
+
44
+ except Exception as e:
45
+ logger.error(f"ERROR: [FIREBASE] Initialization failed: {e}")
46
+
47
+ def upload_file(self, local_path: str, destination_blob_name: str) -> str:
48
+ """
49
+ Uploads a local file to the bucket and returns the public URL.
50
+ """
51
+ if not self._bucket:
52
+ logger.error("❌ [FIREBASE] Not initialized. Cannot upload.")
53
+ return None
54
+
55
+ try:
56
+ blob = self._bucket.blob(destination_blob_name)
57
+ blob.upload_from_filename(local_path)
58
+ blob.make_public()
59
+
60
+ logger.info(f"UPLOAD: [FIREBASE] Uploaded {local_path} -> {blob.public_url}")
61
+ return blob.public_url
62
+
63
+ except Exception as e:
64
+ logger.error(f"ERROR: [FIREBASE] Upload failed for {local_path}: {e}")
65
+ return None
66
+
67
+ # Singleton accessor
68
+ firebase_manager = FirebaseManager()
fix_welcome_audio.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import os
3
+ from audio_generator import generate_teacher_audio
4
+
5
+ # הטקסט של המורה מדף הבית
6
+ WELCOME_SCRIPT = (
7
+ "היי! ברוכים הבאים למורה למתמטיקה. איזה כיף שהצטרפתם אליי! "
8
+ "אני כאן כדי להפוך את המתמטיקה לחוויה ברורה וחזותית. "
9
+ "במסך כאן תמצאו שני כפתורים עיקריים. "
10
+ "לחצו על הכפור הירוק פתרי לי תרגיל, אם אתם צריכים פתרון מלא ומודרך מאפס. "
11
+ "או על הכפתור הוורוד בדקי לי תשובה, כדי שאעבור על הדרך שאתם עשיתם ואגיד לכם אם צדקתם. "
12
+ "חשוב שתדעו, אני תמיד כאן בשבילכם. "
13
+ "אם סיימתי לפתור ועדיין משהו לא מובן, פשוט תלחצו על הכפתור שאל את המורה. "
14
+ "אני אשמח להסביר הכל שוב עד שהכל יהיה ברור ומסודר בראש. "
15
+ "ועוד טיפ קטן ממני. "
16
+ "אם אתם יושבים ללמוד למבחן, הכי כדאי לעבוד עם מחשב ולא עם הטלפון, כי שם הכל הרבה יותר ברור. "
17
+ "כדי להתחבר, פשוט נכנסים לאתר מהמחשב ומאשרים את הכניסה דרך הטלפון שלכם. "
18
+ "שיהיה המון בהצלחה!"
19
+ )
20
+
21
+ async def fix_welcome():
22
+ print("TTS: Generating welcome audio in MP3 format...")
23
+ output_path = "static/welcome_teacher_v1.mp3"
24
+
25
+ # שימוש במחולל האודיו הקיים שכבר יודע להעלות ל-Firebase
26
+ public_url = await generate_teacher_audio(WELCOME_SCRIPT, output_path)
27
+
28
+ if public_url:
29
+ print(f"SUCCESS: Welcome audio fixed and uploaded!")
30
+ print(f"URL: {public_url}")
31
+ else:
32
+ print("ERROR: Failed to generate or upload welcome audio.")
33
+
34
+ if __name__ == "__main__":
35
+ asyncio.run(fix_welcome())
generate_new_welcome.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import time
4
+ import torch
5
+ import torchaudio
6
+ import warnings
7
+ from pydub import AudioSegment
8
+
9
+ warnings.filterwarnings("ignore")
10
+
11
+ # =========================================================================
12
+ # פתרון חסין לטעינה על CPU
13
+ # =========================================================================
14
+ original_load = torch.load
15
+
16
+ def safe_load(*args, **kwargs):
17
+ if 'map_location' not in kwargs:
18
+ kwargs['map_location'] = 'cpu'
19
+ if 'weights_only' in kwargs:
20
+ kwargs['weights_only'] = False
21
+ return original_load(*args, **kwargs)
22
+
23
+ torch.load = safe_load
24
+ # =========================================================================
25
+
26
+ try:
27
+ from chatterbox.mtl_tts import ChatterboxMultilingualTTS
28
+ from phonikud_onnx import Phonikud
29
+ from phonikud import lexicon
30
+ except ImportError:
31
+ print("❌ שגיאה: חסרות ספריות. וודא שאתה ב-venv_fix והרצת pip install.")
32
+ exit()
33
+
34
+ class TTSManager:
35
+ def __init__(self, reference_audio_path="new_teacher_reference.wav"):
36
+ print("⚙️ טוען מנוע 'המורה למתמטיקה' (ZipVoice Mode)...")
37
+ self.device = "cpu"
38
+ self.ref_audio_path = reference_audio_path
39
+ self.phonikud_model = Phonikud("phonikud-1.0.int8.onnx")
40
+ self.tts_model = ChatterboxMultilingualTTS.from_pretrained(device=self.device)
41
+ print("✅ המנוע מוכן.")
42
+
43
+ def _split_to_sentences(self, text):
44
+ return [s.strip() for s in re.split(r'(?<=[.!?]) +', text) if s.strip()]
45
+
46
+ def generate_audio(self, text, output_filename="welcome_speech_v2.wav"):
47
+ sentences = self._split_to_sentences(text)
48
+ print(f"📝 מעבד {len(sentences)} משפטים קצרים (למניעת 'נשימות')...")
49
+
50
+ combined = AudioSegment.empty()
51
+ silence = AudioSegment.silent(duration=250)
52
+
53
+ for i, sentence in enumerate(sentences):
54
+ temp_file = f"temp_part_{i}.wav"
55
+ print(f"🎙️ משפט {i+1}/{len(sentences)}: {sentence[:30]}...")
56
+
57
+ # שלב 1: ניקוד אוטומטי
58
+ voweled = self.phonikud_model.add_diacritics(sentence)
59
+ voweled = re.sub(fr"[{lexicon.NON_STANDARD_DIAC}]", "", voweled)
60
+
61
+ # שלב 2: תיקון כפוי לנקבה! מחליף "מורֶה" ב-"מורָה"
62
+ voweled = voweled.replace("מוֹרֶה", "מוֹרָה").replace("מּוֹרֶה", "מּוֹרָה")
63
+
64
+ # ייצור הקול
65
+ wav = self.tts_model.generate(
66
+ voweled,
67
+ language_id="he",
68
+ audio_prompt_path=self.ref_audio_path,
69
+ cfg_weight=0.90
70
+ )
71
+ torchaudio.save(temp_file, wav, self.tts_model.sr)
72
+ combined += AudioSegment.from_wav(temp_file) + silence
73
+ os.remove(temp_file)
74
+
75
+ combined.export(output_filename, format="wav")
76
+ print(f"✨ הושלם! הקובץ מחכה ב: {output_filename}")
77
+
78
+ if __name__ == "__main__":
79
+ REFERENCE_FILE = "new_teacher_reference.wav"
80
+
81
+ if not os.path.exists(REFERENCE_FILE):
82
+ print(f"❌ שגיאה: קובץ דגימה '{REFERENCE_FILE}' לא נמצא.")
83
+ else:
84
+ tts = TTSManager(reference_audio_path=REFERENCE_FILE)
85
+
86
+ # הטקסט פוצל למשפטים קצרים עם נקודות למניעת רעשי נשימה ועומס על המודל
87
+ script = (
88
+ "היי! ברוכים הבאים למורה למתמטיקה. איזה כיף שהצטרפתם אליי! "
89
+ "אני כאן כדי להפוך את המתמטיקה לחוויה ברורה וחזותית. "
90
+ "במסך כאן תמצאו שני כפתורים עיקריים. "
91
+ "לחצו על הכפתור הירוק פתרי לי תרגיל, אם אתם צריכים פתרון מלא ומודרך מאפס. "
92
+ "או על הכפתור הוורוד בדקי לי תשובה, כדי שאעבור על הדרך שאתם עשיתם ואגיד לכם אם צדקתם. "
93
+ "חשוב שתדעו, אני תמיד כאן בשבילכם. "
94
+ "אם סיימתי לפתור ועדיין משהו לא מובן, פשוט תלחצו על הכפתור שאל את המורה. "
95
+ "אני אשמח להסביר הכל שוב עד שהכל יהיה ברור ומסודר בראש. "
96
+ "ועוד טיפ קטן ממני. "
97
+ "אם אתם יושבים ללמוד למבחן, הכי כדאי לעבוד עם מחשב ולא עם הטלפון, כי שם הכל הרבה יותר ברור. "
98
+ "כדי להתחבר, פשוט נכנסים לאתר מהמחשב ומאשרים את הכניסה דרך הטלפון שלכם. "
99
+ "שיהיה המון בהצלחה!"
100
+ )
101
+
102
+ tts.generate_audio(script, "welcome_speech_v2.wav")
gibberish_detector.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # gibberish_detector.py - V274.0 (Multi-line LaTeX Fix)
2
+ # STABLE VERSION - No aggressive Hebrew removal!
3
+ import re, copy, asyncio
4
+ from typing import Tuple, Dict, Any, List
5
+
6
+ print("[BIT-LOG: GibberishDetector V274.0 Loaded (Multi-line LaTeX Fix)]")
7
+
8
+ # ==================== REPAIR MAPS ====================
9
+
10
+ # Simple replace fixes (Not regex)
11
+ REPAIR_MAP = {
12
+ '\\\\\\\\\\\\\\\\': '\\\\\\\\',
13
+ '\\\\\\\\': '\\\\',
14
+ '$$$': '$$',
15
+ 'תוילקבמ': 'מקביליות',
16
+ 'םיחטש': 'שטחים',
17
+ 'הדוקנה': 'הנקודה',
18
+ }
19
+
20
+ # Broken LaTeX patterns (Regex)
21
+ LATEX_FIXES = {
22
+ r'\\rac\{': r'\\frac{', # Missing 'f'
23
+ r'\\eta\{': r'\\beta{', # Wrong letter
24
+ r'\\sqr\{': r'\\sqrt{', # Missing 't'
25
+ r'(?<!\\)\b(sin|cos|tan|log|ln)\b': r'\\\1', # Missing backslash for funcs
26
+ r'(?<!\\)\b(alpha|beta|gamma|cdot)\b': r'\\\1', # Missing backslash for greek/symbols
27
+ }
28
+
29
+ PROTECTED_FIELDS = {'section_title', 'title', 'teacher_tip', 'teacher_closing'}
30
+ MATH_ALLOWED_FIELDS = {'block_math', 'formulas', 'final_answer', 'function', 'derivative'}
31
+
32
+ # ==================== DETECTION ====================
33
+
34
+ def detect_gibberish_advanced(text: str) -> dict:
35
+ """
36
+ Advanced gibberish detection.
37
+
38
+ Returns:
39
+ {
40
+ "has_issues": bool,
41
+ "issues": [...]
42
+ }
43
+ """
44
+ issues = []
45
+
46
+ # 1. Reversed Hebrew
47
+ for wrong, correct in REPAIR_MAP.items():
48
+ if wrong in text and len(wrong) > 3:
49
+ issues.append({
50
+ "type": "REVERSED_HEBREW",
51
+ "wrong": wrong,
52
+ "correct": correct,
53
+ "fixable": True
54
+ })
55
+
56
+ # 2. Broken LaTeX
57
+ for pattern, fix in LATEX_FIXES.items():
58
+ if re.search(pattern, text):
59
+ issues.append({
60
+ "type": "BROKEN_LATEX",
61
+ "pattern": pattern,
62
+ "fix": fix,
63
+ "fixable": True
64
+ })
65
+
66
+ # 3. Hebrew inside $...$
67
+ hebrew_in_math = re.findall(r'\$[^$]*[\u0590-\u05FF][^$]*\$', text)
68
+ for match in hebrew_in_math:
69
+ issues.append({
70
+ "type": "HEBREW_IN_MATH",
71
+ "line": match,
72
+ "fixable": False,
73
+ "needs_llm": True
74
+ })
75
+
76
+ # 4. Double $$ issues
77
+ if '$$$$' in text or '$$ $' in text:
78
+ issues.append({
79
+ "type": "DOUBLE_DOLLARS",
80
+ "fixable": True,
81
+ "fix": "$$"
82
+ })
83
+
84
+ return {
85
+ "has_issues": len(issues) > 0,
86
+ "issues": issues
87
+ }
88
+
89
+
90
+ # ==================== AUTO-FIX ====================
91
+
92
+ def fix_multiline_latex_blocks(text: str) -> str:
93
+ """
94
+ V274.0: Fix multi-line LaTeX blocks that Flutter can't render.
95
+
96
+ Converts:
97
+ $$$$line1
98
+ line2
99
+ line3$$$$
100
+
101
+ To:
102
+ $$line1$$
103
+ $$line2$$
104
+ $$line3$$
105
+ """
106
+ def split_block(match):
107
+ content = match.group(1)
108
+ lines = [line.strip() for line in content.split('\n') if line.strip()]
109
+ return '\n'.join(f'$${line}$$' for line in lines)
110
+
111
+ return re.sub(r'\$\$\$\$(.*?)\$\$\$\$', split_block, text, flags=re.DOTALL)
112
+
113
+
114
+ def auto_fix_gibberish(text: str) -> str:
115
+ """Auto-fix simple gibberish issues with regex/replace."""
116
+ if not text:
117
+ return text
118
+
119
+ fixed = text
120
+
121
+ # V274.0: Fix multi-line LaTeX blocks FIRST
122
+ fixed = fix_multiline_latex_blocks(fixed)
123
+
124
+ # Fix simple artifacts
125
+ for wrong, correct in REPAIR_MAP.items():
126
+ fixed = fixed.replace(wrong, correct)
127
+
128
+ # Fix broken LaTeX
129
+ for pattern, replacement in LATEX_FIXES.items():
130
+ fixed = re.sub(pattern, replacement, fixed)
131
+
132
+ # Fix dollar sign glitches
133
+ fixed = fixed.replace('$$ $', '$$')
134
+ fixed = fixed.replace('$ $$', '$$')
135
+
136
+ return fixed
137
+
138
+
139
+ # ==================== LLM RETRY ====================
140
+
141
+ async def fix_line_with_llm(problematic_line: str, llm_model) -> str:
142
+ """Use LLM to fix complex gibberish (e.g., Hebrew in math)."""
143
+ prompt = f"""FIX THIS LINE ONLY:
144
+ Problematic: "{problematic_line}"
145
+
146
+ Rules:
147
+ 1. Hebrew text OUTSIDE $...$
148
+ 2. Math INSIDE $...$
149
+ 3. NO Hebrew inside math delimiters
150
+
151
+ Output: Fixed line ONLY (one line, no explanation)
152
+ """
153
+
154
+ try:
155
+ response = await asyncio.wait_for(
156
+ llm_model.generate_content_async(prompt),
157
+ timeout=10.0
158
+ )
159
+ return response.text.strip()
160
+ except Exception as e:
161
+ print(f"⚠️ [GIBBERISH] LLM fix failed: {e}")
162
+ return problematic_line
163
+
164
+
165
+ # ==================== SMART FIX ====================
166
+
167
+ async def fix_gibberish_smart(text: str, llm_model=None) -> str:
168
+ """Smart gibberish fixing."""
169
+ fixed = auto_fix_gibberish(text)
170
+ result = detect_gibberish_advanced(fixed)
171
+
172
+ if not result["has_issues"]:
173
+ return fixed
174
+
175
+ if llm_model:
176
+ for issue in result["issues"]:
177
+ if issue.get("needs_llm"):
178
+ line = issue["line"]
179
+ fixed_line = await fix_line_with_llm(line, llm_model)
180
+ fixed = fixed.replace(line, fixed_line)
181
+
182
+ return fixed
183
+
184
+
185
+ # ==================== LEGACY INTERFACE ====================
186
+
187
+ def validate_and_fix_solution(solution: Dict[str, Any]) -> Tuple[Dict[str, Any], bool, str]:
188
+ """Legacy interface - now uses auto-fix"""
189
+ fixed = copy.deepcopy(solution)
190
+
191
+ def process(obj, field=""):
192
+ if isinstance(obj, dict):
193
+ return {k: process(v, k) for k, v in obj.items()}
194
+ if isinstance(obj, list):
195
+ return [process(i, field) for i in obj]
196
+ if isinstance(obj, str):
197
+ return auto_fix_gibberish(_fix_string(obj, field))
198
+ return obj
199
+
200
+ return process(fixed), False, ""
201
+
202
+
203
+ def _fix_string(text: str, field: str) -> str:
204
+ """Legacy string fixing"""
205
+ if not text or text.lower() in ["none", "null"]:
206
+ return text
207
+
208
+ res = text.replace(r'\f\frac', r'\frac')
209
+
210
+ # V231.2: Skip $$ wrapping for :: format
211
+ if '::' in res:
212
+ for bad, good in REPAIR_MAP.items():
213
+ res = res.replace(bad, good)
214
+ return res
215
+
216
+ # Hebrew-Safe $$ Rule — only for pure-math fields
217
+ if '\\' in res and '$$' not in res and field in MATH_ALLOWED_FIELDS:
218
+ if not bool(re.search(r'[\u0590-\u05FF]', res)):
219
+ res = f"$${res.replace('$', '')}$$"
220
+
221
+ for bad, good in REPAIR_MAP.items():
222
+ res = res.replace(bad, good)
223
+
224
+ return res
225
+
226
+
227
+ def format_solution(data):
228
+ return data
229
+
230
+
231
+ # ==================== USAGE EXAMPLE ====================
232
+
233
+ if __name__ == "__main__":
234
+ test_text = "נשתמש בנוסחה $x = \\frac{1}{2}$ ונקבל \\rac{x}{y}"
235
+
236
+ result = detect_gibberish_advanced(test_text)
237
+ print(f"Issues found: {result}")
238
+
239
+ fixed = auto_fix_gibberish(test_text)
240
+ print(f"Auto-fixed: {fixed}")
implementation_plan.md ADDED
File without changes
logs/usage.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
main.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # main.py - V5.8.0 (MULTIPART & OPENCV BASE + INFRA HARDENING)
2
+ from contextlib import asynccontextmanager
3
+ from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Request
4
+ from fastapi.responses import JSONResponse
5
+ from fastapi.middleware.cors import CORSMiddleware
6
+ from fastapi.staticfiles import StaticFiles
7
+ from sse_starlette.sse import EventSourceResponse
8
+ from typing import Optional
9
+ import logging
10
+ import base64
11
+ import json
12
+ import io
13
+ import sys
14
+ import os
15
+ import asyncio
16
+ from pydantic import BaseModel
17
+ from typing import Any
18
+
19
+ class AskQuestionRequest(BaseModel):
20
+ context_data: dict | Any
21
+ question: str
22
+ student_name: str = "תלמיד"
23
+
24
+ # --- HEALTH CHECK : Top-level Dependency Verification ---
25
+ # We do this before standard imports to ensure a clear error message
26
+ # if the virtual environment is inactive and libraries are missing.
27
+ try:
28
+ import cv2
29
+ import numpy as np
30
+ except ModuleNotFoundError as e:
31
+ print(f"🔥 [HEALTH-CHECK FAILED] Missing critical dependency: {e}. Are you running inside the .venv?")
32
+ sys.exit(1)
33
+
34
+ from orchestrator import orchestrator, build_standard_response
35
+ from quota_system import quota_manager
36
+ from config import IS_PRODUCTION, ENV
37
+
38
+ if hasattr(sys.stdout, 'reconfigure'):
39
+ sys.stdout.reconfigure(encoding='utf-8')
40
+
41
+ # הגדרת לוגר HamoraServer
42
+ try:
43
+ logging.basicConfig(
44
+ level=logging.INFO,
45
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
46
+ handlers=[
47
+ logging.FileHandler("server.log", encoding="utf-8"),
48
+ logging.StreamHandler(sys.stdout)
49
+ ]
50
+ )
51
+ except PermissionError:
52
+ logging.basicConfig(
53
+ level=logging.INFO,
54
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
55
+ handlers=[logging.StreamHandler(sys.stdout)]
56
+ )
57
+
58
+ logger = logging.getLogger("HamoraServer")
59
+
60
+ # --- INFRA HARDENING: Global Async Exception Handler ---
61
+ def custom_async_exception_handler(loop, context):
62
+ """
63
+ Catches unhandled asynchronous exceptions to prevent the Event Loop from crashing.
64
+ """
65
+ msg = context.get("exception", context["message"])
66
+ logger.critical(f"🚨 [ASYNC-CRASH-PREVENTION] Caught unhandled exception in Event Loop: {msg}")
67
+ # The loop remains alive. We just log the critical error.
68
+
69
+ # --- INFRA HARDENING: Health Check Function ---
70
+ def verify_system_health():
71
+ """
72
+ Verifies execution environment and critical dependencies.
73
+ """
74
+ logger.info("🩺 [HEALTH-CHECK] Verifying core dependencies and environment...")
75
+
76
+ # Check if running in a virtual environment
77
+ if sys.prefix == sys.base_prefix:
78
+ logger.warning("⚠️ [HEALTH-CHECK] Not running inside a virtual environment (.venv). Proceeding anyway...")
79
+
80
+ logger.info(f"✅ [HEALTH-CHECK] cv2 version: {cv2.__version__}, numpy: {np.__version__}")
81
+ logger.info(f"✅ [HEALTH-CHECK] Environment: {ENV.upper()}, Production Mode: {IS_PRODUCTION}")
82
+
83
+ # --- INFRA HARDENING: Lifespan Context Manager ---
84
+ @asynccontextmanager
85
+ async def lifespan(app: FastAPI):
86
+ # Startup Phase
87
+ verify_system_health()
88
+
89
+ # Register Global Async Exception Handler
90
+ loop = asyncio.get_running_loop()
91
+ loop.set_exception_handler(custom_async_exception_handler)
92
+ logger.info("🛡️ [STARTUP] Global Async Exception Handler registered.")
93
+
94
+ yield # Yield control back to FastAPI
95
+
96
+ # Shutdown Phase
97
+ logger.info("🛑 [SHUTDOWN] BuddyMath Server is shutting down cleanly.")
98
+
99
+
100
+ # Application Setup
101
+ app = FastAPI(title="BuddyMath Server - OpenCV Engine", lifespan=lifespan)
102
+
103
+ app.add_middleware(
104
+ CORSMiddleware,
105
+ allow_origins=["*"],
106
+ allow_credentials=True,
107
+ allow_methods=["*"],
108
+ allow_headers=["*"],
109
+ )
110
+
111
+ # Static files for audio fallback
112
+ os.makedirs("/tmp/static", exist_ok=True)
113
+ app.mount("/static", StaticFiles(directory="/tmp/static"), name="static")
114
+
115
+ @app.get("/")
116
+ async def root():
117
+ return {"status": f"BuddyMath API V5.8.0 ({ENV.upper()})", "engine": "OpenCV Base + Infra Hardening"}
118
+
119
+ @app.post("/solve_stream")
120
+ async def solve_stream(
121
+ user: Optional[str] = Form(None),
122
+ student_name: Optional[str] = Form(None),
123
+ grade: str = Form("י'"),
124
+ student_gender: str = Form("M"),
125
+ mode: str = Form("solve"),
126
+ user_note: Optional[str] = Form(None),
127
+ file: UploadFile = File(...)
128
+ ):
129
+ """
130
+ V5.8.0: המורה למתמטיקה - Multipart & OpenCV Base.
131
+ מקבל קובץ ישירות מהפלאטר ומפענח אותו עם OpenCV.
132
+ """
133
+ final_student_name = student_name or user or "תלמיד"
134
+ print(f"🚀 🟢 BIT-LOG: Received Multipart request from {final_student_name}. Grade: {grade}")
135
+
136
+ # Quota Check
137
+ is_allowed, msg, current_usage, limit = quota_manager.check_limit(final_student_name)
138
+ if not is_allowed:
139
+ response_content = build_standard_response(
140
+ final_answer=f"הגעת למכסה היומית ({limit} שאלות)",
141
+ teacher_summary="נא להמתין למחר לקבלת מכסה חדשה.",
142
+ logic_error=True,
143
+ response_type="error"
144
+ )
145
+ response_content["error"] = "QUOTA_EXCEEDED"
146
+ return JSONResponse(status_code=429, content=response_content)
147
+ quota_manager.increment_usage(final_student_name)
148
+
149
+ try:
150
+ # 1. קריאת הבינארי
151
+ image_bytes = await file.read()
152
+ print(f"📸 [BIT-LOG] Image received. Size: {len(image_bytes)} bytes")
153
+
154
+ # 2. OpenCV Decoder
155
+ nparr = np.frombuffer(image_bytes, np.uint8)
156
+ img_cv2 = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
157
+
158
+ if img_cv2 is None:
159
+ print("❌ [BIT-LOG] OpenCV failed to decode image!")
160
+ raise HTTPException(status_code=400, detail="Invalid image data")
161
+
162
+ print(f"✅ [BIT-LOG] OpenCV Matrix Ready: {img_cv2.shape}")
163
+
164
+ # 3. OCR & Solving Pipeline (Streaming)
165
+ print("🚀 [TRACE-MAIN] Initiating streaming orchestrator.solve_problem...")
166
+
167
+ async def event_generator():
168
+ try:
169
+ async for event in orchestrator.solve_problem(
170
+ problem_text="", # Will be extracted by OCR
171
+ grade=grade,
172
+ student_name=final_student_name,
173
+ student_gender=student_gender,
174
+ user_note=user_note,
175
+ image_data=image_bytes,
176
+ mode=mode
177
+ ):
178
+ # SSE Protocol: yield a dict with "data" key
179
+ yield {
180
+ "event": "message",
181
+ "id": event.question_id,
182
+ "data": event.model_dump_json() # Pydantic v2
183
+ }
184
+ except Exception as e:
185
+ logger.error(f"STREAMING ERROR: {e}")
186
+ yield {
187
+ "event": "error",
188
+ "data": json.dumps({"error": str(e)})
189
+ }
190
+
191
+ return EventSourceResponse(event_generator())
192
+
193
+ except Exception as e:
194
+ logger.exception("CRITICAL FLOW ERROR")
195
+ print(f"🔥 [BIT-LOG] CRITICAL ERROR: {str(e)}")
196
+ import traceback
197
+ traceback.print_exc()
198
+ response_content = build_standard_response(
199
+ final_answer="שגיאה בפענוח התמונה או התרגיל",
200
+ teacher_summary="המורה למתמטיקה מתנצל, אך חלה שגיאה לא צפויה.",
201
+ logic_error=True,
202
+ response_type="error"
203
+ )
204
+ return JSONResponse(status_code=500, content=response_content)
205
+
206
+ @app.post("/explain_step")
207
+ async def explain_step(request: Request):
208
+ data = await request.json()
209
+ res = await orchestrator.explain_specific_step(data.get("context"), data.get("step_text"), data.get("student_name"))
210
+ return JSONResponse(content=res)
211
+
212
+ @app.post("/ask_question")
213
+ async def ask_question(request: AskQuestionRequest):
214
+ data = request.dict()
215
+ res = await orchestrator.ask_question(data.get("context_data"), data.get("question"), data.get("student_name"))
216
+ return JSONResponse(content=res)
217
+
218
+ if __name__ == "__main__":
219
+ import uvicorn
220
+ uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True)
math_engine.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sympy as sp
2
+
3
+ class MathContext:
4
+ """
5
+ A sandbox for the LLM to execute mathematics.
6
+ Each operation is recorded with Hebrew context and exact LaTeX representation.
7
+ """
8
+ def __init__(self):
9
+ self.steps = []
10
+
11
+ # Injected SymPy helpers
12
+ self.sp = sp
13
+ self.Eq = sp.Eq
14
+ self.solve = sp.solve
15
+ self.expand = sp.expand
16
+ self.simplify = sp.simplify
17
+ self.sqrt = sp.sqrt
18
+ self.diff = sp.diff
19
+
20
+ def _format_latex(self, expr) -> str:
21
+ """Converts SymPy to precise LaTeX, without $$ so it renders correctly in Flutter."""
22
+ if isinstance(expr, str):
23
+ # If the LLM passed a string instead of SymPy object, try parsing it
24
+ try:
25
+ expr = sp.sympify(expr)
26
+ except:
27
+ pass # Return as-is if not parseable
28
+
29
+ latex_str = sp.latex(expr)
30
+ return latex_str
31
+
32
+ def explain(self, text: str):
33
+ """Adds a pure Hebrew explanation step without math."""
34
+ self.steps.append({
35
+ "content_mixed": text,
36
+ "block_math": ""
37
+ })
38
+
39
+ def declare_equation(self, text: str, eq: sp.Eq):
40
+ """Prints a known equation with its explanation."""
41
+ self.steps.append({
42
+ "content_mixed": text,
43
+ "block_math": self._format_latex(eq)
44
+ })
45
+ return eq
46
+
47
+ def expand_expr(self, text: str, expr):
48
+ """Expands an expression completely (e.g. squaring a root)."""
49
+ expanded = sp.expand(expr)
50
+ self.steps.append({
51
+ "content_mixed": text,
52
+ "block_math": self._format_latex(expanded)
53
+ })
54
+ return expanded
55
+
56
+ def solve_equation(self, text: str, eq: sp.Eq, var):
57
+ """Solves an equation for a specific variable."""
58
+ solutions = sp.solve(eq, var)
59
+
60
+ # Format solutions nicely
61
+ if isinstance(solutions, list):
62
+ if len(solutions) == 1:
63
+ math_result = sp.Eq(var, solutions[0])
64
+ else:
65
+ # E.g. x_1 = 2, x_2 = -2
66
+ parts = [f"{sp.latex(var)}_{{{i+1}}} = {sp.latex(sol)}" for i, sol in enumerate(solutions)]
67
+ math_result = " \\text{ ואו } ".join(parts)
68
+ else:
69
+ math_result = sp.Eq(var, solutions)
70
+
71
+ self.steps.append({
72
+ "content_mixed": text,
73
+ "block_math": math_result if isinstance(math_result, str) else self._format_latex(math_result)
74
+ })
75
+ return solutions
76
+
77
+ def finish(self, final_answer: str, teacher_summary: str = ""):
78
+ """Sets the final human-readable answer for the UI."""
79
+ self.final_answer = final_answer
80
+ self.teacher_summary = teacher_summary
81
+
82
+ def run_llm_code(python_code: str) -> dict:
83
+ """
84
+ Executes the LLM-generated Python code in our secure MathContext.
85
+ Returns the step-by-step UI format required by BuddyMath.
86
+ """
87
+ ctx = MathContext()
88
+
89
+ # Secure Globals the LLM is allowed to use
90
+ safe_globals = {
91
+ "ctx": ctx,
92
+ "x": sp.Symbol('x'),
93
+ "y": sp.Symbol('y'),
94
+ "a": sp.Symbol('a'),
95
+ "b": sp.Symbol('b'),
96
+ "c": sp.Symbol('c'),
97
+ "m": sp.Symbol('m'),
98
+ "R": sp.Symbol('R'),
99
+ "sp": sp,
100
+ }
101
+
102
+ try:
103
+ # Execute the LLM's dynamically generated mathematics
104
+ exec(python_code, safe_globals)
105
+
106
+ return {
107
+ "success": True,
108
+ "steps": ctx.steps,
109
+ "final_answer": getattr(ctx, 'final_answer', "הגענו לפתרון."),
110
+ "teacher_summary": getattr(ctx, 'teacher_summary', "")
111
+ }
112
+ except Exception as e:
113
+ return {
114
+ "success": False,
115
+ "error": str(e)
116
+ }
117
+
118
+ if __name__ == "__main__":
119
+ # Simulate LLM output to solve a Locus / Parabola problem:
120
+ # "The distance from (x, y) to (2, 0) is equal to its distance to x = -2."
121
+
122
+ llm_code = """
123
+ ctx.explain("נסמן את הנקודה הכללית על המקום הגיאומטרי כ- (x,y). לפי הנתון, המרחק מהמוקד שווה למרחק מהמדריך.")
124
+ d1 = sp.sqrt((x - 2)**2 + (y - 0)**2) # מוקד
125
+ d2 = sp.sqrt((x - (-2))**2) # מדריך
126
+
127
+ eq1 = ctx.declare_equation("המשוואה המשווה בין המרחקים היא:", ctx.Eq(d1, d2))
128
+
129
+ ctx.explain("נעלה את שני האגפים בריבוע כדי להיפטר מהשורש:")
130
+ # SymPy understands squaring both sides! We square them and declare equality.
131
+ squared_eq = ctx.Eq(d1**2, d2**2)
132
+ ctx.steps[-1]["block_math"] = ctx._format_latex(squared_eq) # Override previous block math
133
+
134
+ # Now expand and simplify it beautifully
135
+ expanded_eq = ctx.declare_equation("נרחיב את הביטויים (פתיחת סוגריים מלאה):", ctx.Eq(ctx.expand(squared_eq.lhs), ctx.expand(squared_eq.rhs)))
136
+
137
+ # SymPy's powerful simplify equation solver (subtract RHS from LHS)
138
+ simplified_expr = ctx.simplify(expanded_eq.lhs - expanded_eq.rhs)
139
+ final_eq = ctx.declare_equation("לאחר כינוס איברים והעברת אגפים, נקבל את צורת הפרבולה הפשוטה:", ctx.Eq(simplified_expr, 0))
140
+
141
+ # Also isolate y^2 if needed
142
+ y_sq_isolated = ctx.solve(final_eq, y**2)
143
+ if y_sq_isolated:
144
+ ctx.declare_equation("נבודד את y^2 במשוואה:", ctx.Eq(y**2, y_sq_isolated[0]))
145
+
146
+ ctx.finish("$$ y^2 = 8x $$")
147
+ """
148
+
149
+ print("🚀 Running LLM Mathematics Script:")
150
+ result = run_llm_code(llm_code)
151
+
152
+ import json
153
+ # Print the resulting UI object
154
+ print(json.dumps(result, indent=2, ensure_ascii=False))
math_intent_detector.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # math_intent_detector.py - V4.2 (Intent Lockdown)
2
+ # Rigidly classifies user intent before mathematical planning.
3
+
4
+ import re
5
+ import logging
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ # Rigid intent categories
10
+ INTENT_SIMPLE_LINEAR = "SIMPLE_LINEAR_SOLVE"
11
+ INTENT_WORD_PROBLEM_BASIC = "WORD_PROBLEM_BASIC"
12
+ INTENT_FUNCTION_ANALYSIS = "FUNCTION_ANALYSIS"
13
+ INTENT_SYSTEM_EQUATIONS = "SYSTEM_EQUATIONS"
14
+ INTENT_GENERAL = "GENERAL"
15
+ # V286.0: New intent categories for 5-unit math coverage
16
+ INTENT_VECTORS_3D = "VECTORS_3D"
17
+ INTENT_SOLID_GEOMETRY = "SOLID_GEOMETRY"
18
+ INTENT_OPTIMIZATION = "OPTIMIZATION"
19
+ INTENT_PROBABILITY = "PROBABILITY"
20
+ INTENT_TRIGONOMETRY = "TRIGONOMETRY"
21
+ INTENT_LOGARITHMS = "LOGARITHMS"
22
+ INTENT_LIMITS = "LIMITS"
23
+
24
+ def detect_intent(text: str, grade_num: int) -> str:
25
+ """
26
+ Detects the rigid intent category based on keywords and grade boundaries.
27
+ Focuses on 'Simplifying' intent for lower grades.
28
+ """
29
+ text_lower = text.lower()
30
+
31
+ # 1. Simple Linear Solve (Grade 7 focus)
32
+ # Check for basic algebra keywords and lack of complex concepts
33
+ simple_linear_keywords = ["x=", "פתור", "משוואה", "נעלם"]
34
+ is_simple_algebra = any(kw in text_lower for kw in simple_linear_keywords)
35
+
36
+ # Exclusion markers for simple intent
37
+ complex_markers = ["נגזרת", "קיצון", "מערכת", "שני נעלמים", "x^2", "x²"]
38
+ has_complex_content = any(cm in text_lower for cm in complex_markers)
39
+
40
+ if grade_num == 7 and is_simple_algebra and not has_complex_content:
41
+ print("🎯 [INTENT] Classified as SIMPLE_LINEAR_SOLVE (Grade 7 Lockdown)")
42
+ return INTENT_SIMPLE_LINEAR
43
+
44
+ # 2. Word Problem Basic
45
+ word_problem_keywords = ["בעיה", "מילולית", "קנה", "מחיר", "סך הכל"]
46
+ if any(kw in text_lower for kw in word_problem_keywords):
47
+ print("🎯 [INTENT] Classified as WORD_PROBLEM_BASIC")
48
+ return INTENT_WORD_PROBLEM_BASIC
49
+
50
+ # 3. System of Equations
51
+ if "מערכת" in text_lower or "שני נעלמים" in text_lower:
52
+ print("🎯 [INTENT] Classified as SYSTEM_EQUATIONS")
53
+ return INTENT_SYSTEM_EQUATIONS
54
+
55
+ # 4. Function Analysis
56
+ function_markers = ["נגזרת", "קיצון", "חקירה", "פונקציה", "f(x)"]
57
+ if any(fm in text_lower for fm in function_markers):
58
+ print("🎯 [INTENT] Classified as FUNCTION_ANALYSIS")
59
+ return INTENT_FUNCTION_ANALYSIS
60
+
61
+ # V286.0: 5-unit intent categories
62
+ # 5. Vectors 3D
63
+ if any(kw in text_lower for kw in ["וקטור", "וקטורים", "מכפלה סקלרית", "מכפלה וקטורית"]):
64
+ if any(kw in text_lower for kw in ["מרחב", "מישור", "תלת", "z"]):
65
+ print("🎯 [INTENT] Classified as VECTORS_3D")
66
+ return INTENT_VECTORS_3D
67
+
68
+ # 6. Solid Geometry
69
+ if any(kw in text_lower for kw in ["פירמידה", "מנסרה", "תיבה", "חתך", "אפותם", "פאות"]):
70
+ print("🎯 [INTENT] Classified as SOLID_GEOMETRY")
71
+ return INTENT_SOLID_GEOMETRY
72
+
73
+ # 7. Optimization
74
+ if any(kw in text_lower for kw in ["מקסימום", "מינימום", "מקסימלי", "מינימלי", "ערך מרבי", "ערך מזערי", "שטח גדול ביותר", "נפח מקסימלי"]):
75
+ print("🎯 [INTENT] Classified as OPTIMIZATION")
76
+ return INTENT_OPTIMIZATION
77
+
78
+ # 8. Probability
79
+ if any(kw in text_lower for kw in ["הסתברות", "קומבינטור", "תמורה", "צירוף", "בייס"]):
80
+ print("🎯 [INTENT] Classified as PROBABILITY")
81
+ return INTENT_PROBABILITY
82
+
83
+ # 9. Trigonometry
84
+ if any(kw in text_lower for kw in ["sin", "cos", "tan", "טריגונומטר", "סינוס", "קוסינוס"]):
85
+ print("🎯 [INTENT] Classified as TRIGONOMETRY")
86
+ return INTENT_TRIGONOMETRY
87
+
88
+ # 10. Logarithms
89
+ if any(kw in text_lower for kw in ["לוגריתם", "log", "ln", "מעריכי"]):
90
+ print("🎯 [INTENT] Classified as LOGARITHMS")
91
+ return INTENT_LOGARITHMS
92
+
93
+ # 11. Limits
94
+ if any(kw in text_lower for kw in ["גבול", "lim", "שואף", "לופיטל"]):
95
+ print("🎯 [INTENT] Classified as LIMITS")
96
+ return INTENT_LIMITS
97
+
98
+ def _extract_grade_number(text: str) -> int:
99
+ import re
100
+ # מחלץ מספר מתוך מחרונה (למשל "ז׳" או "כיתה 7")
101
+ match = re.search(r'\d+', text)
102
+ if match:
103
+ return int(match.group())
104
+ # מיפוי ידני למקרים של אותיות
105
+ mapping = {'ז': 7, 'ח': 8, 'ט': 9, 'י': 10, 'יא': 11, 'יב': 12}
106
+ for char, num in mapping.items():
107
+ if char in text:
108
+ return num
109
+ return -1
110
+
111
+ def get_intent_contract(intent: str, grade_num: int) -> dict:
112
+ """
113
+ Returns the 'Action Contract' for the given intent.
114
+ This contract is used to constrain the LLM/Solver.
115
+ """
116
+ if intent == INTENT_SIMPLE_LINEAR and grade_num == 7:
117
+ return {
118
+ "max_variables": 1,
119
+ "allowed_operators": ["ADD", "SUB", "MUL", "DIV"],
120
+ "forbidden_strategies": ["DERIVATIVES", "SYSTEM_OF_EQUATIONS", "COMPLEX_SUBSTITUTION"],
121
+ "variable_preference": "x",
122
+ "narrative_tone": "simple_step_by_step"
123
+ }
124
+
125
+ if intent == INTENT_WORD_PROBLEM_BASIC:
126
+ return {
127
+ "max_variables": 2 if grade_num > 8 else 1,
128
+ "focus": "translation_to_algebra",
129
+ "forbidden_strategies": ["CALCULUS"]
130
+ }
131
+
132
+ return {"status": "unconstrained"}
math_parser.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import json
3
+ import logging
4
+ from typing import List, Dict
5
+ from enum import Enum
6
+
7
+ logger = logging.getLogger("MathParser")
8
+
9
+ class MathMode(Enum):
10
+ INLINE = "inline"
11
+ DISPLAY = "display"
12
+
13
+ class MathSegmenter:
14
+ @staticmethod
15
+ def segment_text_with_math(text: str) -> List[Dict]:
16
+ """
17
+ Convert natural text with $math$ to structured segments.
18
+ Combines V68 protections with V70 logic.
19
+ """
20
+ # 1. Memory Protection (V68 Feature)
21
+ if not text:
22
+ return [{"type": "text", "value": "", "direction": "rtl"}]
23
+
24
+ if len(text) > 50000:
25
+ logger.warning(f"⚠️ Input text too long ({len(text)}), truncating.")
26
+ text = text[:50000] + "... (truncated)"
27
+
28
+ if '$' not in text:
29
+ return [{"type": "text", "value": text.strip(), "direction": "rtl"}]
30
+
31
+ segments = []
32
+ pos = 0
33
+ text_len = len(text)
34
+ max_iterations = 5000 # Safety limit
35
+
36
+ iteration = 0
37
+ while pos < text_len and iteration < max_iterations:
38
+ iteration += 1
39
+ dollar_idx = text.find('$', pos)
40
+
41
+ if dollar_idx == -1:
42
+ remaining = text[pos:]
43
+ if remaining.strip():
44
+ segments.append(MathSegmenter._create_text_segment(remaining))
45
+ break
46
+
47
+ is_double = (dollar_idx + 1 < text_len and text[dollar_idx + 1] == '$')
48
+
49
+ if dollar_idx > pos:
50
+ text_before = text[pos:dollar_idx]
51
+ if text_before.strip():
52
+ segments.append(MathSegmenter._create_text_segment(text_before))
53
+
54
+ if is_double:
55
+ close_idx = text.find('$$', dollar_idx + 2)
56
+ if close_idx == -1:
57
+ segments.append(MathSegmenter._create_text_segment('$$'))
58
+ pos = dollar_idx + 2
59
+ continue
60
+ math_content = text[dollar_idx + 2:close_idx].strip()
61
+ if math_content:
62
+ segments.append({"type": "math", "mode": "display", "value": math_content, "direction": "ltr"})
63
+ pos = close_idx + 2
64
+ else:
65
+ close_idx = text.find('$', dollar_idx + 1)
66
+ if close_idx == -1:
67
+ segments.append(MathSegmenter._create_text_segment('$'))
68
+ pos = dollar_idx + 1
69
+ continue
70
+ math_content = text[dollar_idx + 1:close_idx].strip()
71
+ if math_content:
72
+ segments.append({"type": "math", "mode": "inline", "value": math_content, "direction": "ltr"})
73
+ pos = close_idx + 1
74
+
75
+ if not segments:
76
+ return [{"type": "text", "value": text, "direction": "rtl"}]
77
+
78
+ return MathSegmenter._merge_text_segments(segments)
79
+
80
+ @staticmethod
81
+ def _create_text_segment(text: str) -> Dict:
82
+ cleaned = text
83
+ # Safe RLM injection (V70 Logic)
84
+ RLM = chr(0x200f)
85
+ if cleaned.strip() and MathSegmenter._ends_with_hebrew(cleaned.strip()):
86
+ cleaned = cleaned.rstrip() + RLM
87
+ return {"type": "text", "value": cleaned, "direction": "rtl"}
88
+
89
+ @staticmethod
90
+ def _ends_with_hebrew(text: str) -> bool:
91
+ if not text: return False
92
+ last_char = text[-1]
93
+ return '\u0590' <= last_char <= '\u05FF'
94
+
95
+ @staticmethod
96
+ def _merge_text_segments(segments: List[Dict]) -> List[Dict]:
97
+ if not segments: return []
98
+ merged = []
99
+ curr_text = ""
100
+ for seg in segments:
101
+ if seg["type"] == "text":
102
+ curr_text += seg["value"]
103
+ else:
104
+ if curr_text:
105
+ merged.append(MathSegmenter._create_text_segment(curr_text))
106
+ curr_text = ""
107
+ merged.append(seg)
108
+ if curr_text:
109
+ merged.append(MathSegmenter._create_text_segment(curr_text))
110
+ return merged
111
+
112
+ @staticmethod
113
+ def segments_to_safe_string(segments: List[Dict]) -> str:
114
+ """
115
+ Reconstructs string safely.
116
+ Uses ONLY RLM (V70 Safe Logic) - NO LRE/PDF to avoid stream crashes.
117
+ """
118
+ result_parts = []
119
+ RLM = chr(0x200f)
120
+
121
+ for i, segment in enumerate(segments):
122
+ if segment["type"] == "text":
123
+ text = segment["value"]
124
+ # Ensure Hebrew ends with RLM
125
+ if text.strip() and MathSegmenter._ends_with_hebrew(text.strip()):
126
+ if not text.endswith(RLM):
127
+ text += RLM
128
+ result_parts.append(text)
129
+
130
+ elif segment["type"] == "math":
131
+ math_val = segment["value"]
132
+
133
+ # Spacing logic
134
+ prefix = ""
135
+ suffix = ""
136
+ if i > 0 and segments[i-1]["type"] == "text":
137
+ if not segments[i-1]["value"].endswith(" ") and not segments[i-1]["value"].endswith(RLM):
138
+ prefix = " "
139
+ if i < len(segments)-1 and segments[i+1]["type"] == "text":
140
+ if not segments[i+1]["value"].startswith(" "):
141
+ suffix = " "
142
+
143
+ # Simple wrap: Just Math + RLM.
144
+ if segment.get("mode") == "display":
145
+ formatted_math = f"$${math_val}$${RLM}"
146
+ else:
147
+ formatted_math = f"${math_val}${RLM}"
148
+
149
+ result_parts.append(prefix + formatted_math + suffix)
150
+
151
+ return "".join(result_parts)
152
+
153
+ @staticmethod
154
+ def aggregate_math(segments: List[Dict]) -> str:
155
+ """
156
+ Collects all math for the gray box.
157
+ Uses simple keywords (V70 Safe Logic) to avoid Regex OOM.
158
+ """
159
+ math_blocks = []
160
+ keywords = ['=', '\\frac', '\\sqrt', '\\cdot', 'A', 'B', 'C', 'D', 'M', 'x', 'y']
161
+
162
+ for seg in segments:
163
+ if seg["type"] == "math":
164
+ val = seg["value"]
165
+ if seg.get("mode") == "display":
166
+ clean_val = val.replace('$$', '')
167
+ math_blocks.append(clean_val)
168
+ else:
169
+ if len(val) > 1 and any(k in val for k in keywords):
170
+ math_blocks.append(val)
171
+
172
+ seen = set()
173
+ unique_blocks = []
174
+ for m in math_blocks:
175
+ if m not in seen:
176
+ unique_blocks.append(m)
177
+ seen.add(m)
178
+
179
+ # Guard against huge aggregation (V68 Protection)
180
+ result = " \\\\ ".join(unique_blocks)
181
+ if len(result) > 2000:
182
+ return result[:2000] + "..."
183
+ return result
math_sanitizer.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # math_sanitizer.py - V1.1 ProductionMathSanitizer
2
+ import re
3
+ import logging
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+ class ProductionMathSanitizer:
8
+ @staticmethod
9
+ def normalize_latex(latex_str: str) -> str:
10
+ """
11
+ V1.1: Standardizes LaTeX for SymPy and LLM comparison.
12
+ """
13
+ if not latex_str: return ""
14
+
15
+ # 1. Basic Cleaning
16
+ clean = latex_str.strip()
17
+ clean = clean.replace(r'\ ', '')
18
+ clean = clean.replace(r'\times', '*')
19
+ clean = clean.replace(r'\cdot', '*')
20
+
21
+ # 2. Bracket Normalization
22
+ clean = clean.replace(r'\left(', '(').replace(r'\right)', ')')
23
+ clean = clean.replace(r'\left[', '[').replace(r'\right]', ']')
24
+ clean = clean.replace('{', '(').replace('}', ')')
25
+
26
+ # 3. Fractions
27
+ while r'\frac' in clean:
28
+ clean = re.sub(r'\\frac\s*\((.*?)\)\((.*?)\)', r'(\1)/(\2)', clean)
29
+ if r'\frac' in clean and '(' not in clean: # Fallback for simple fractions
30
+ clean = re.sub(r'\\frac\s*(.*?)\s*(.*?)', r'(\1)/(\2)', clean)
31
+
32
+ # 4. Implicit Multiplication Guard (V1.1)
33
+ clean = re.sub(r'(\d)([a-zA-Z(])', r'\1*\2', clean)
34
+ clean = re.sub(r'\)([a-zA-Z0-9(])', r')*\1', clean)
35
+
36
+ return clean
37
+
38
+ @staticmethod
39
+ def validate_semantic_completeness(anchor_data: dict, formula_tokens: list[str]) -> bool:
40
+ """
41
+ V1.1: Partial Semantic Recovery Check.
42
+ Returns True if the missing tokens are non-critical.
43
+ """
44
+ # Logic to check if critical variables/values are missing
45
+ # For now, a simple check if the main function key is present.
46
+ critical_keys = ['function_equations', 'equations']
47
+ for key in critical_keys:
48
+ if key in anchor_data and anchor_data[key]:
49
+ return True
50
+ return False
51
+
52
+ @staticmethod
53
+ def get_symbolic_bridge(proof_graph) -> str:
54
+ """
55
+ V1.1: Zero Hallucination Bridge.
56
+ Converts the Immutable ProofGraph to a clean mathematical context for the LLM.
57
+ """
58
+ bridge = "════════════════════════════════════════\n"
59
+ bridge += "📜 VERIFIED SYMBOLIC BRIDGE (V1.1):\n"
60
+ bridge += "════════════════════════════════════════\n"
61
+ for step in proof_graph.steps:
62
+ bridge += f"Step {step.step_id}: {step.math_content} ({step.logic_description or ''})\n"
63
+
64
+ # V6 Ontology Injection
65
+ if hasattr(step, 'allowed_concepts') and getattr(step, 'allowed_concepts'):
66
+ concepts_str = ", ".join(step.allowed_concepts)
67
+ tag = getattr(step, 'pedagogical_tag', 'כללי')
68
+ bridge += f"For step {step.step_id}, your pedagogical_tag is '{tag}'. You MUST build your explanation using ONLY the concepts from this list: [{concepts_str}]. Do NOT introduce any other mathematical concepts. Keep it under 2 sentences.\n"
69
+
70
+ bridge += "════════════════════════════════════════\n"
71
+ bridge += "RULE: USE ONLY THE DATA ABOVE. DO NOT HALLUCINATE OR CHANGE MATH.\n"
72
+ return bridge
73
+
74
+ def sanitize_math_ocr_hotfix(text: str) -> str:
75
+ """
76
+ V1.1.1 Aggressive Sanitizer: Removes all spaces and fixes frac regex.
77
+ Fixes failures caused by leading spaces or visual artifacts.
78
+ """
79
+ if not text: return ""
80
+
81
+ # תיקון קריטי: הסרת כל הרווחים למניעת כשלי Regex (פתרון לשאלה 2 ו-3)
82
+ text = text.replace(" ", "")
83
+
84
+ # ניקוי שאריות ויזואליות
85
+ text = text.replace("\\left", "").replace("\\right", "")
86
+
87
+ # נרמול שברים (עובד עכשיו על מחרוזת נקייה מרווחים)
88
+ import re
89
+ text = re.sub(
90
+ r"frac\(([^()]+)\)\(([^()]+)\)",
91
+ lambda m: f"(({m.group(1)})/({m.group(2)}))",
92
+ text
93
+ )
94
+ return text.strip()
memory_service.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # memory_service.py
2
+ """
3
+ Buddy Math - Memory Service (Pinecone Edition)
4
+ ==============================================
5
+ גרסה v2.1: שיפור מנגנון זיהוי הדמיון.
6
+ במקום לבדוק סדר מילים (שנשבר ב-OCR), בודקים חפיפת מילים (Jaccard).
7
+ """
8
+
9
+ import os
10
+ import json
11
+ import logging
12
+ import uuid
13
+ import re
14
+ from typing import Optional, Dict
15
+
16
+ from sentence_transformers import SentenceTransformer
17
+ from pinecone import Pinecone
18
+
19
+ logger = logging.getLogger("MemoryService")
20
+
21
+ class MemoryService:
22
+ def __init__(self):
23
+ self.api_key = os.environ.get("PINECONE_API_KEY")
24
+
25
+ if not self.api_key:
26
+ logger.warning("⚠️ PINECONE_API_KEY not found! Memory will be disabled.")
27
+ self.index = None
28
+ return
29
+
30
+ try:
31
+ self.pc = Pinecone(api_key=self.api_key)
32
+ self.index_name = "buddy-math"
33
+ self.index = self.pc.Index(self.index_name)
34
+
35
+ logger.info("⏳ Loading embedding model...")
36
+ self.embedder = SentenceTransformer('all-MiniLM-L6-v2')
37
+
38
+ logger.info("✅ Brain initialized (Pinecone + MiniLM)")
39
+
40
+ except Exception as e:
41
+ logger.error(f"❌ Failed to init Pinecone: {e}")
42
+ self.index = None
43
+
44
+ def find_similar_solution(self, problem_text: str, vector_threshold: float = 0.85) -> Optional[Dict]:
45
+ """
46
+ מחפש פתרון בזיכרון.
47
+ מבצע אימות כפול: וקטורי (משמעות) + חפיפת מילים (תוכן).
48
+ """
49
+ if not self.index: return None
50
+
51
+ try:
52
+ # 1. חיפוש וקטורי (מהיר)
53
+ vector = self.embedder.encode(problem_text).tolist()
54
+
55
+ results = self.index.query(
56
+ vector=vector,
57
+ top_k=1,
58
+ include_metadata=True
59
+ )
60
+
61
+ if not results['matches']:
62
+ return None
63
+
64
+ match = results['matches'][0]
65
+ vector_score = match['score']
66
+
67
+ # בדיקת סף וקטורי
68
+ if vector_score < vector_threshold:
69
+ return None
70
+
71
+ # שליפת הטקסט המקורי מהזיכרון
72
+ cached_text = match['metadata'].get('text', '')
73
+
74
+ # 2. בדיקת דמיון משופרת (Jaccard Similarity)
75
+ # בודקים כמה מילים משותפות יש, בלי קשר לסדר
76
+ text_similarity = self._calculate_jaccard_similarity(problem_text, cached_text)
77
+
78
+ logger.info(f"🧠 Brain Check: Vector={vector_score:.3f}, Jaccard={text_similarity:.3f}")
79
+
80
+ # הורדנו את הרף ל-30% חפיפה (מספיק לזיהוי אותה שאלה ב-OCR משובש)
81
+ if text_similarity < 0.3:
82
+ logger.warning("⚠️ High vector score but low text overlap. Ignoring.")
83
+ return None
84
+
85
+ # שליפת ה-JSON
86
+ solution_json = match['metadata'].get('solution_json')
87
+ if solution_json:
88
+ logger.info("🧠 Brain HIT! Verified match found.")
89
+ return json.loads(solution_json)
90
+
91
+ return None
92
+
93
+ except Exception as e:
94
+ logger.error(f"Memory search failed: {e}")
95
+ return None
96
+
97
+ def learn_solution(self, problem_text: str, solution_data: dict):
98
+ """שומר פתרון חדש"""
99
+ if not self.index: return
100
+
101
+ try:
102
+ clean_text = problem_text.strip()
103
+ if len(clean_text) < 10: return
104
+
105
+ vector = self.embedder.encode(clean_text).tolist()
106
+ json_str = json.dumps(solution_data, ensure_ascii=False)
107
+
108
+ # הגנה: Pinecone מגביל Metadata ל-40KB
109
+ if len(json_str.encode('utf-8')) > 38000:
110
+ logger.warning("⚠️ Solution too big for memory. Skipping save.")
111
+ return
112
+
113
+ metadata = {
114
+ "text": clean_text[:1000],
115
+ "topic": solution_data.get("meta", {}).get("topic", "unknown"),
116
+ "solution_json": json_str
117
+ }
118
+
119
+ self.index.upsert(vectors=[{
120
+ "id": str(uuid.uuid4()),
121
+ "values": vector,
122
+ "metadata": metadata
123
+ }])
124
+
125
+ logger.info("🧠 Brain LEARNED and saved to Cloud!")
126
+
127
+ except Exception as e:
128
+ logger.error(f"Memory learn failed: {e}")
129
+
130
+ def _calculate_jaccard_similarity(self, text1: str, text2: str) -> float:
131
+ """
132
+ מחשב דמיון לפי חפיפת מילים (מתעלם מסדר המילים).
133
+ טוב ל-OCR עברית/אנגלית שמתהפך.
134
+ """
135
+ # ניקוי ופירוק למילים ייחו��יות (Tokens)
136
+ tokens1 = self._tokenize(text1)
137
+ tokens2 = self._tokenize(text2)
138
+
139
+ if not tokens1 or not tokens2:
140
+ return 0.0
141
+
142
+ # חיתוך (מילים משותפות) חלקי איחוד (כל המילים)
143
+ intersection = len(tokens1.intersection(tokens2))
144
+ union = len(tokens1.union(tokens2))
145
+
146
+ return intersection / union
147
+
148
+ def _tokenize(self, text: str) -> set:
149
+ """מפרק טקסט לסט של מילים נקיות"""
150
+ # משאיר רק אותיות ומספרים
151
+ clean = re.sub(r'[^\w\s]', '', text)
152
+ # פירוק למילים
153
+ words = clean.lower().split()
154
+ # מסנן מילים קצרות מדי (כמו "של", "את")
155
+ return {w for w in words if len(w) > 1}
micro_prompts.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # micro_prompts.py — Textbook JSON / Hebrew Centric
2
+
3
+ # Topic-specific micro-prompts for BuddyMath
4
+ # ALL TEMPLATES: enforce [{type: "text"|"math", content: "..."}] JSON only.
5
+ # ZERO mentions of ctx, sp, sympy, MathEngine, or Python.
6
+
7
+ """
8
+ Micro-Prompt Library
9
+ Each entry adds topic-specific focus on TOP of the specialist prompt in prompts.py.
10
+ The specialist prompt already defines the full Textbook JSON schema — these just
11
+ focus the LLM on which formula/technique to use.
12
+ """
13
+
14
+ import json # needed by get_general_prompt
15
+
16
+ # ==================== MICRO-PROMPT TEMPLATES ====================
17
+ # Each template is a SHORT topic-focused instruction.
18
+ # It is prepended to the full specialist prompt (which already has the JSON schema).
19
+ # DO NOT add any output format instructions here — the specialist prompt handles that.
20
+
21
+ MICRO_PROMPTS = {
22
+
23
+ # ========== GEOMETRY ==========
24
+
25
+ "CIRCLE_EQUATION": {
26
+ "template": """🎯 נושא: משוואת מעגל / מקום גיאומטרי
27
+ השתמש בנוסחה: (x-a)² + (y-b)² = r²
28
+ זהה: מרכז (a,b) ורדיוס r מהנתונים. הצב. פשט. הגדר.</p>""",
29
+ "tokens": 60,
30
+ "requires": []
31
+ },
32
+
33
+ "CIRCLE_TANGENT": {
34
+ "template": """🎯 נושא: משיק למעגל בנקודה
35
+ שלב 1: בדוק שהנקודה על המעגל.
36
+ שלב 2: מצא שיפוע הרדיוס (מהמרכז לנקודה).
37
+ שלב 3: שיפוע המשיק = -1 / שיפוע הרדיוס (מאונך). כתוב משוואת ישר.""",
38
+ "tokens": 70,
39
+ "requires": []
40
+ },
41
+
42
+ "TRIANGLE_AREA": {
43
+ "template": """🎯 נושא: שטח משולש
44
+ נוסחה: S = ½ × בסיס × גובה. הצב וחשב.""",
45
+ "tokens": 40,
46
+ "requires": []
47
+ },
48
+
49
+ "LINE_EQUATION": {
50
+ "template": """🎯 נושא: משוואת ישר
51
+ מצא שיפוע m ונקודת חיתוך b. כתוב: y = mx + b.""",
52
+ "tokens": 40,
53
+ "requires": []
54
+ },
55
+
56
+ "LINE_PERPENDICULAR": {
57
+ "template": """🎯 נושא: ישר מאונך
58
+ כלל: m₁ × m₂ = -1. מצא שיפוע המאונך, הצב נקודה, כתוב משוואה.""",
59
+ "tokens": 50,
60
+ "requires": []
61
+ },
62
+
63
+ "DISTANCE_FORMULA": {
64
+ "template": """🎯 נושא: מרחק בין שתי נקודות
65
+ נוסחה: d = √[(x₂-x₁)² + (y₂-y₁)²]. הצב מספרים. פשט.""",
66
+ "tokens": 50,
67
+ "requires": []
68
+ },
69
+
70
+ # ========== CALCULUS ==========
71
+
72
+ "DERIVATIVE_POWER": {
73
+ "template": """🎯 נושא: נגזרת — כלל החזקה
74
+ כלל: (xⁿ)' = n·xⁿ⁻¹. גזור כל איבר בנפרד. פשט.""",
75
+ "tokens": 50,
76
+ "requires": []
77
+ },
78
+
79
+ "DERIVATIVE_QUOTIENT": {
80
+ "template": """🎯 נושא: נגזרת — כלל המנה
81
+ כלל: (u/v)' = (u'v - uv') / v². זהה u ו-v. גזור כל אחד. הצב.""",
82
+ "tokens": 60,
83
+ "requires": []
84
+ },
85
+
86
+ "DERIVATIVE_PRODUCT": {
87
+ "template": """🎯 נושא: נגזרת — כלל המכפלה
88
+ כלל: (u·v)' = u'v + uv'. זהה u ו-v. גזור כל אחד. הצב.""",
89
+ "tokens": 60,
90
+ "requires": []
91
+ },
92
+
93
+ "DERIVATIVE_CHAIN": {
94
+ "template": """🎯 נושא: נגזרת — כלל השרשרת
95
+ כלל: [f(g(x))]' = f'(g(x)) · g'(x). זהה פונקציה חיצונית ופנימית. גזור.""",
96
+ "tokens": 70,
97
+ "requires": []
98
+ },
99
+
100
+ "INVESTIGATION_EXTREMA": {
101
+ "template": """🎯 נושא: חישוב נקודות קיצון
102
+ שלב 1: f'(x) = 0 — מצא נקודות אפשריות.
103
+ שלב 2: בדוק ב-f''(x) — f''>0 → מינימום, f''<0 → מקסימום.
104
+ שלב 3: הצב בחזרה ב-f(x) למציאת ערך y.""",
105
+ "tokens": 90,
106
+ "requires": []
107
+ },
108
+
109
+ "INVESTIGATION_MONOTONICITY": {
110
+ "template": """🎯 נושא: עליה וירידה של פונקציה
111
+ מצא f'(x). פתור f'(x) = 0. בדוק סימן f'(x) בכל קטע.
112
+ f'>0 → עולה, f'<0 → יורדת.""",
113
+ "tokens": 80,
114
+ "requires": []
115
+ },
116
+
117
+ # ========== ALGEBRA ==========
118
+
119
+ "LINEAR_EQUATION": {
120
+ "template": """🎯 נושא: משוואה לינארית
121
+ פתור שלב-אחר-שלב: בודד x בצד אחד.""",
122
+ "tokens": 30,
123
+ "requires": []
124
+ },
125
+
126
+ "QUADRATIC_EQUATION": {
127
+ "template": """🎯 נושא: משוואה ריבועית
128
+ השתמש בנוסחת פתרון: x = [-b ± √(b²-4ac)] / 2a
129
+ חשב דיסקרימיננטה. מצא שתי תשובות (או שורש כפול).""",
130
+ "tokens": 60,
131
+ "requires": []
132
+ },
133
+
134
+ "SYSTEM_EQUATIONS": {
135
+ "template": """🎯 נושא: מערכת משוואות
136
+ פתור בשיטת הצבה או החסרה. הצב x חזרה למשוואה לווידוא.""",
137
+ "tokens": 60,
138
+ "requires": []
139
+ },
140
+ }
141
+
142
+
143
+ # ==================== PROMPT BUILDER ====================
144
+
145
+ def get_micro_prompt(topic_id: str, data: dict, grade: str = "10") -> str:
146
+ """
147
+ Returns topic-focused prefix only.
148
+ Safety filter: Middle school (7-9) cannot use CALCULUS topics.
149
+ """
150
+ if topic_id not in MICRO_PROMPTS:
151
+ raise KeyError(f"Topic '{topic_id}' not found in MICRO_PROMPTS")
152
+
153
+ # Safety Filter for V4.2.11
154
+ is_middle_school = False
155
+ try:
156
+ grade_val = int(re.search(r'\d+', str(grade)).group())
157
+ is_middle_school = 7 <= grade_val <= 9
158
+ except:
159
+ pass
160
+
161
+ if is_middle_school and ("DERIVATIVE" in topic_id or "INVESTIGATION" in topic_id):
162
+ print(f"🛡️ [Safety Filter] Micro-Prompt Filter: Topic {topic_id} blocked for Grade {grade}. Falling back to GENERAL.")
163
+ return get_general_prompt(data)
164
+
165
+ config = MICRO_PROMPTS[topic_id]
166
+ template = config["template"]
167
+
168
+ # No required fields anymore — all templates are self-contained
169
+ focus_block = template.strip()
170
+
171
+ # Prepend focus to the general prompt (which has the anchor + schema)
172
+ return f"{focus_block}\n\n{get_general_prompt(data)}"
173
+
174
+
175
+ def get_prompt_token_count(topic_id: str) -> int:
176
+ """Get estimated token count for topic's micro-prompt"""
177
+ return MICRO_PROMPTS.get(topic_id, {}).get("tokens", 60)
178
+
179
+
180
+ def get_general_prompt(data_anchor: dict) -> str:
181
+ """
182
+ Clean context provider.
183
+ Provides the data anchor without conflicting schemas.
184
+ """
185
+ anchor_json = json.dumps(data_anchor, ensure_ascii=False, indent=2)
186
+ return f"""📊 [DATA ANCHOR] Problem context (Absolute Truth):
187
+ {anchor_json}
188
+
189
+ Instruction: Use the specific data above to solve the problem.
190
+ Do not provide any explanations outside the required JSON structure."""
191
+
192
+
193
+
194
+ # ==================== USAGE EXAMPLE ====================
195
+
196
+ if __name__ == "__main__":
197
+ test_data = {"equations": ["x^2 - 4 = 0"], "function_equations": ["f(x) = ln(x)/(x^2-4)"]}
198
+ for topic in ["CIRCLE_EQUATION", "DERIVATIVE_QUOTIENT", "LINEAR_EQUATION"]:
199
+ prompt = get_micro_prompt(topic, test_data)
200
+ print(f"=== {topic} ===\n{prompt[:200]}\n")
ocr_strip_engine.py ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ocr_strip_engine.py - V303.2 (Adaptive Pipeline & Two-Pass Sniper)
2
+ import os
3
+ import io
4
+ import json
5
+ import re
6
+ import logging
7
+ import asyncio
8
+ from pathlib import Path
9
+ import numpy as np
10
+ from PIL import Image
11
+ import cv2
12
+ from utils.safe_json import safe_extract_json # V1.0: Canonical JSON extractor
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ DEBUG_DIR = Path("temp/debug_ocr")
17
+ DEBUG_DIR.mkdir(parents=True, exist_ok=True)
18
+
19
+ # --- Constants for block detection ---
20
+ MIN_BLOCK_W = 50
21
+ MIN_BLOCK_H = 10
22
+ LARGE_BLOCK_H = 100
23
+ ROW_MERGE_GAP = 2
24
+
25
+ def _adaptive_preprocess(image_bytes: bytes) -> np.ndarray:
26
+ """
27
+ V303.2: Adaptive Preprocessing Pipeline
28
+ Decides how aggressively to process based on image size.
29
+ """
30
+ np_img_raw = np.frombuffer(image_bytes, np.uint8)
31
+ img_bgr = cv2.imdecode(np_img_raw, cv2.IMREAD_COLOR)
32
+ file_size_kb = len(image_bytes) / 1024
33
+
34
+ logger.info(f"📸 [OCR-ADAPTIVE] Input image size: {file_size_kb:.1f} KB")
35
+
36
+ if file_size_kb < 500:
37
+ # --- LOW-RES MODE (PC Screenshots / Snips) ---
38
+ logger.info("🔧 [OCR-ADAPTIVE] Low-Res mode triggered: Upscaling and applying heavy morphology.")
39
+ # 1. Upscale x2 to save thin pixels
40
+ img_bgr = cv2.resize(img_bgr, None, fx=2.0, fy=2.0, interpolation=cv2.INTER_CUBIC)
41
+ gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
42
+ # 2. Strong CLAHE
43
+ clahe = cv2.createCLAHE(clipLimit=2.5, tileGridSize=(8,8))
44
+ cl1 = clahe.apply(gray)
45
+ # 3. Morph Close (thicken lines)
46
+ kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2))
47
+ processed = cv2.morphologyEx(cl1, cv2.MORPH_CLOSE, kernel)
48
+ else:
49
+ # --- HIGH-RES MODE (Phone Camera in Production) ---
50
+ logger.info("📱 [OCR-ADAPTIVE] High-Res mode triggered: Mild enhancement only.")
51
+ gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
52
+
53
+ # V1.1: Pre-normalization (Contrast/Brightness Balance)
54
+ alpha = 1.2 # Contrast
55
+ beta = 10 # Brightness
56
+ gray = cv2.convertScaleAbs(gray, alpha=alpha, beta=beta)
57
+
58
+ # Mild CLAHE just to balance lighting, NO morphological distortion
59
+ clahe = cv2.createCLAHE(clipLimit=1.5, tileGridSize=(8,8))
60
+ processed = clahe.apply(gray)
61
+
62
+ return cv2.cvtColor(processed, cv2.COLOR_GRAY2BGR)
63
+
64
+
65
+ def _find_raw_blocks(np_bgr: np.ndarray) -> list[tuple]:
66
+ gray = cv2.cvtColor(np_bgr, cv2.COLOR_BGR2GRAY)
67
+ blur = cv2.GaussianBlur(gray, (7, 7), 0)
68
+ thresh = cv2.adaptiveThreshold(blur, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2)
69
+ # Kernel optimized for both modes
70
+ kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (60, 3))
71
+ dilate = cv2.dilate(thresh, kernel, iterations=1)
72
+ contours, _ = cv2.findContours(dilate, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
73
+ blocks = []
74
+ for c in contours:
75
+ x, y, w, h = cv2.boundingRect(c)
76
+ if w >= MIN_BLOCK_W and h >= MIN_BLOCK_H:
77
+ blocks.append((x, y, w, h))
78
+ blocks.sort(key=lambda b: b[1])
79
+ return blocks
80
+
81
+ def _filter_nested(blocks: list[tuple]) -> list[tuple]:
82
+ filtered = []
83
+ for i, (x1, y1, w1, h1) in enumerate(blocks):
84
+ r1, b1 = x1 + w1, y1 + h1
85
+ nested = False
86
+ for j, (x2, y2, w2, h2) in enumerate(blocks):
87
+ if i == j: continue
88
+ r2, b2 = x2 + w2, y2 + h2
89
+ if x1 >= x2 and y1 >= y2 and r1 <= r2 and b1 <= b2:
90
+ nested = True
91
+ break
92
+ if not nested: filtered.append((x1, y1, w1, h1))
93
+ return filtered
94
+
95
+ def _merge_same_row(blocks: list[tuple]) -> list[tuple]:
96
+ if not blocks: return []
97
+ merged = []
98
+ cur_x, cur_y, cur_w, cur_h = blocks[0]
99
+ for (x, y, w, h) in blocks[1:]:
100
+ cur_b = cur_y + cur_h
101
+ if cur_h >= LARGE_BLOCK_H or h >= LARGE_BLOCK_H:
102
+ merged.append((cur_x, cur_y, cur_w, cur_h))
103
+ cur_x, cur_y, cur_w, cur_h = x, y, w, h
104
+ continue
105
+ if y <= cur_b + ROW_MERGE_GAP:
106
+ union_x, union_y = min(cur_x, x), min(cur_y, y)
107
+ union_r, union_b = max(cur_x + cur_w, x + w), max(cur_y + cur_h, y + h)
108
+ cur_x, cur_y, cur_w, cur_h = union_x, union_y, union_r - union_x, union_b - union_y
109
+ else:
110
+ merged.append((cur_x, cur_y, cur_w, cur_h))
111
+ cur_x, cur_y, cur_w, cur_h = x, y, w, h
112
+ merged.append((cur_x, cur_y, cur_w, cur_h))
113
+ return merged
114
+
115
+ def _extract_blocks(np_bgr: np.ndarray) -> list[tuple]:
116
+ raw = _find_raw_blocks(np_bgr)
117
+ dedup = _filter_nested(raw)
118
+ return _merge_same_row(dedup)
119
+
120
+ def get_best_sniper_roi(img):
121
+ """
122
+ V1.1: Math Structural Heatmap Prior.
123
+ תעדוף אזורים עם צפיפות סמלים מתמטיים גבוהה.
124
+ """
125
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
126
+ _, thresh = cv2.threshold(gray, 150, 255, cv2.THRESH_BINARY_INV)
127
+ contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
128
+
129
+ ROI_HOMOGRAPHY_THRESHOLD = 50000 # Threshold for local correction (w*h)
130
+ candidates = []
131
+
132
+ for cnt in contours:
133
+ x, y, w, h = cv2.boundingRect(cnt)
134
+ if w < 20 or h < 10: continue
135
+
136
+ # V1.1: Symbol Density Check (Heatmap Prior)
137
+ roi_thresh = thresh[y:y+h, x:x+w]
138
+ pixel_count = np.sum(roi_thresh == 255)
139
+ density = pixel_count / (w * h)
140
+
141
+ # Feature Clustering (Heatmap Weighting)
142
+ # Higher score for small but high-density clusters (usually math symbols)
143
+ heatmap_prior = density * (1.5 if density > 0.15 else 1.0)
144
+
145
+ # Position Weighting (Top-heavy bias)
146
+ position_weight = (1.0 / (1.0 + 0.005 * y))
147
+
148
+ confidence_score = heatmap_prior * position_weight
149
+
150
+ candidates.append({
151
+ 'confidence': confidence_score,
152
+ 'box': (x, y, w, h),
153
+ 'needs_local_homography': (w * h) > ROI_HOMOGRAPHY_THRESHOLD
154
+ })
155
+
156
+ if not candidates:
157
+ logger.warning("⚠️ No valid candidates found. Fallback.")
158
+ return img[:350, :], 0.0
159
+
160
+ best = max(candidates, key=lambda c: c['confidence'])
161
+ x, y, w, h = best['box']
162
+
163
+ # V8.6.4: ROI Hardening — Safe Margins (Padding 50px)
164
+ # Prevent cutting off minus signs from exponents (e.g., e^-x becoming e^x)
165
+ SAFE_PADDING = 50
166
+ y_start = max(0, y - SAFE_PADDING)
167
+ y_end = min(img.shape[0], y + h + SAFE_PADDING)
168
+
169
+ logger.info(
170
+ f"📐 [OCR-BBOX] Sniper ROI (V8.6.4) — x={x}, y={y}, w={w}, h={h} | "
171
+ f"img=({img.shape[1]}x{img.shape[0]}) | "
172
+ f"crop=[{y_start}:{y_end}, :] | "
173
+ f"confidence={best['confidence']:.3f}"
174
+ )
175
+
176
+ # Conditional Local Homography Warning (Bit-log only for now)
177
+ if best['needs_local_homography']:
178
+ logger.info(f"📐 [V1.1] ROI ({w}x{h}) exceeds threshold. Local adjustment recommended.")
179
+
180
+ return img[y_start:y_end, :], best['confidence']
181
+
182
+ def apply_conditional_homography(roi_img: np.ndarray) -> np.ndarray:
183
+ """
184
+ V1.1: Local Homography Warning.
185
+ Only applies alignment if the ROI is large enough to warrant it.
186
+ Small ROIs stay original to prevent distortion.
187
+ """
188
+ h, w = roi_img.shape[:2]
189
+ area = h * w
190
+
191
+ if area < 5000: # Small ROI - keep original (Prevent distortion)
192
+ logger.info(f"📐 [V1.1] ROI too small ({area}) - skipping local homography.")
193
+ return roi_img
194
+
195
+ # Placeholder for actual homography warp
196
+ # In production, this would involve findHomography + warpPerspective
197
+ logger.info(f"📐 [V1.1] ROI large enough ({area}) - ready for local perspective correction.")
198
+ return roi_img
199
+
200
+ async def transcribe(image_bytes: bytes, vision_model, debug_mode: bool = False) -> tuple[list[dict], float]:
201
+ logger.info("🪡 [OCR-STRIP] V303.6 Production Lock Pipeline Starting...")
202
+
203
+ # 1. Adaptive Preprocessing
204
+ np_enhanced_bgr = _adaptive_preprocess(image_bytes)
205
+ img_h, img_w = np_enhanced_bgr.shape[:2]
206
+
207
+ # 2. Get Sniper ROI (V1.1)
208
+ sniper_bgr, roi_confidence = get_best_sniper_roi(np_enhanced_bgr)
209
+
210
+ # 2b. Apply Local Homography if needed (V1.1)
211
+ # Note: Full homography implementation requires feature matching,
212
+ # for now we implement the threshold logic.
213
+ sniper_bgr = apply_conditional_homography(sniper_bgr)
214
+
215
+ sniper_image = Image.fromarray(cv2.cvtColor(sniper_bgr, cv2.COLOR_BGR2RGB))
216
+
217
+ # 3. Reader Pass (Rest of the image)
218
+ # Note: For V303.6 unified/single pass, we still use the full image for the reader or keep it split if preferred.
219
+ # The user instruction implies a "Single Pass" logic for Strategy, but OCR can stay multi-pass as long as it's targeted.
220
+ # We'll stick to a high-quality reader image of the original.
221
+ pil_enhanced = Image.fromarray(cv2.cvtColor(np_enhanced_bgr, cv2.COLOR_BGR2RGB))
222
+ reader_image = pil_enhanced # Full image for context
223
+
224
+ if debug_mode:
225
+ sniper_image.save(DEBUG_DIR / "ocr_pass1_sniper.jpg")
226
+ reader_image.save(DEBUG_DIR / "ocr_pass2_reader.jpg")
227
+
228
+ # 4. The Prompts
229
+ sniper_prompt = (
230
+ "Extract ONLY the main mathematical function defined in this image. "
231
+ "It is usually preceded by words like 'נתונה הפונקציה'. "
232
+ "CRITICAL: If any exponent or fraction bar appears small or ambiguous, zoom mentally and transcribe it explicitly using ^ notation. "
233
+ "RETURN ONLY A JSON ARRAY: [{\"type\": \"math\", \"content\": \"...\"}]"
234
+ )
235
+
236
+ reader_prompt = (
237
+ "Extract all Hebrew text and secondary mathematical content from this image. "
238
+ "RETURN ONLY A JSON ARRAY: [{\"type\": \"text\"|\"math\", \"content\": \"...\"}]"
239
+ )
240
+
241
+ # 5. Concurrent Processing
242
+ try:
243
+ from google.generativeai.types import GenerationConfig
244
+ gen_config = GenerationConfig(temperature=0.0, top_p=0.1, top_k=1)
245
+ pass1_task = vision_model.generate_content_async([sniper_prompt, sniper_image], generation_config=gen_config)
246
+ pass2_task = vision_model.generate_content_async([reader_prompt, reader_image], generation_config=gen_config)
247
+
248
+ pass1_response, pass2_response = await asyncio.gather(pass1_task, pass2_task)
249
+
250
+ blocks_pass1 = _parse_structured_json(pass1_response.text)
251
+ blocks_pass2 = _parse_structured_json(pass2_response.text)
252
+
253
+ # Merge, prioritizing pass 1 for the function definition
254
+ final_blocks = blocks_pass1 + [b for b in blocks_pass2 if b not in blocks_pass1]
255
+
256
+ logger.info(f"✅ V303.6 Complete. Sniper: {len(blocks_pass1)}, Reader: {len(blocks_pass2)} (Confidence: {roi_confidence:.2f})")
257
+ return final_blocks, roi_confidence
258
+
259
+ except Exception as e:
260
+ logger.exception("CRITICAL FLOW ERROR")
261
+ logger.error(f"❌ OCR V303.6 FAILED: {e}")
262
+ return [{"type": "text", "content": "שגיאת תקשורת בפענוח."}], 0.0
263
+
264
+ def _parse_structured_json(raw_text: str) -> list[dict]:
265
+ """V1.0: Uses canonical safe_extract_json (logs RAW, fail-closed)."""
266
+ result = safe_extract_json(raw_text, caller="OCR", allow_array=True)
267
+ if isinstance(result, list):
268
+ # Flatten nested lists (LLM sometimes wraps array in array)
269
+ flat = []
270
+ for item in result:
271
+ if isinstance(item, list):
272
+ flat.extend(item)
273
+ elif isinstance(item, dict):
274
+ flat.append(item)
275
+ return [p for p in flat if isinstance(p, dict)]
276
+ if isinstance(result, dict) and not result.get("logic_error"):
277
+ return [result]
278
+ logger.error(f"[OCR] _parse_structured_json: parse failed for: {raw_text[:200]!r}")
279
+ return []
280
+
281
+
282
+ def paginate_image(image_bytes, debug_mode=False):
283
+ return [Image.open(io.BytesIO(image_bytes)).convert("RGB")]
284
+
285
+ def flatten_to_text(structured: list[dict]) -> str:
286
+ parts = []
287
+ for item in structured:
288
+ if item.get("type") == "math": parts.append(f"${item.get('content', '')}$")
289
+ else: parts.append(item.get("content", ""))
290
+ return " ".join(parts)
orchestrator.py ADDED
The diff for this file is too large to render. See raw diff
 
pedagogical_builder.py ADDED
@@ -0,0 +1,850 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # pedagogical_builder.py - V231.18
2
+ # Server-side pedagogical templates for BuddyMath
3
+ # Adds detailed explanations WITHOUT using LLM tokens!
4
+
5
+ """
6
+ Pedagogical Builder for BuddyMath
7
+ Follows Iron Law #4: CHILD-FRIENDLY ALWAYS
8
+
9
+ LLM returns ONLY mathematical core (100-200 tokens).
10
+ Server adds full pedagogical wrapper (0 tokens!).
11
+ """
12
+
13
+ import re
14
+ from domain.processing_strategy import ProcessingStrategy
15
+
16
+ class LLMSchemaError(Exception):
17
+ """Custom error for LLM output validation failures."""
18
+ pass
19
+
20
+ # validate_narrative_density (Unified at line 362)
21
+
22
+
23
+
24
+ # ==================== PEDAGOGICAL TEMPLATES ====================
25
+
26
+ PEDAGOGICAL_TEMPLATES = {
27
+ # ========== GEOMETRY ==========
28
+
29
+ "CIRCLE_EQUATION": {
30
+ "intro": {
31
+ "title": "מקום גיאומטרי או משוואת מעגל",
32
+ "content": "בואו נפתור צעד אחר צעד ונשתמש במשוואת המעגל והמרחק לפי הצורך.",
33
+ "tip": "חשוב להבין את המשמעות הגיאומטרית של הנתונים"
34
+ },
35
+ "steps": [
36
+ {
37
+ "title": "מה נתון לנו?",
38
+ "uses_llm": "approach",
39
+ "tip": "תמיד מסדרים את הנתונים קודם"
40
+ },
41
+ {
42
+ "title": "איך מחשבים מרחק?",
43
+ "content": "נזכיר את נוסחת המרחק: המרחק מנקודה $(x,y)$ לנקודה $(a,b)$ הוא $d = \\sqrt{{(x-a)^2 + (y-b)^2}}$",
44
+ "block_math": "d = \\sqrt{(x-a)^2 + (y-b)^2}",
45
+ "tip": "זו נוסחת פיתגורס במסווה!"
46
+ },
47
+ {
48
+ "title": "חישוב הפתרון",
49
+ "uses_llm": "steps"
50
+ },
51
+ {
52
+ "title": "התשובה הסופית",
53
+ "content": "קיבלנו את התשובה הסופית!",
54
+ "uses_llm": "solution"
55
+ }
56
+ ],
57
+ "closing": "כל הכבוד! הצלחנו לפתור את השאלה המורכבת הזו! 🎉"
58
+ },
59
+
60
+ "DERIVATIVE_QUOTIENT": {
61
+ "intro": {
62
+ "title": "נגזרת של מנה",
63
+ "content": "כשיש לנו פונקציה שהיא מנה (חילוק) של שתי פונקציות, נשתמש בכלל המנה.",
64
+ "tip": "כלל המנה: $(\\frac{u}{v})' = \\frac{u'v - uv'}{v^2}$"
65
+ },
66
+ "steps": [
67
+ {
68
+ "title": "זיהוי המונה והמכנה",
69
+ "template": "המונה: $u = {numerator}$\nהמכנה: $v = {denominator}$"
70
+ },
71
+ {
72
+ "title": "נגזרת המונה",
73
+ "content": "נמצא את $u'$ (נגזרת המונה):",
74
+ "uses_llm": "u_prime"
75
+ },
76
+ {
77
+ "title": "נגזרת המכנה",
78
+ "content": "נמצא את $v'$ (נגזרת המכנה):",
79
+ "uses_llm": "v_prime"
80
+ },
81
+ {
82
+ "title": "נציב בכלל המנה",
83
+ "content": "עכשיו נציב בנוסחה: $(\\frac{u}{v})' = \\frac{u'v - uv'}{v^2}$",
84
+ "uses_llm": "derivative",
85
+ "tip": "שים לב לסדר: u'v **פחות** uv'"
86
+ }
87
+ ],
88
+ "closing": "מעולה! שלטת בכלל המנה! 💪"
89
+ },
90
+
91
+ "LINEAR_EQUATION": {
92
+ "intro": {
93
+ "title": "פתרון משוואה לינארית",
94
+ "content": "משוואה לינארית היא משוואה שבה המשתנה מופיע בחזקה 1 בלבד. המטרה: לבודד את x.",
95
+ "tip": "כלל הזהב: מה שעושים בצד אחד, עושים גם בצד השני!"
96
+ },
97
+ "steps": [
98
+ {
99
+ "title": "המשוואה שלנו",
100
+ "template": "נתון: ${equation}$"
101
+ },
102
+ {
103
+ "title": "פתרון שלב אחר שלב",
104
+ "uses_llm": "steps",
105
+ "tip": "בכל שלב, נקרב את x לבידוד"
106
+ },
107
+ {
108
+ "title": "התשובה",
109
+ "template": "הפתרון: $x = {solution}$"
110
+ }
111
+ ],
112
+ "closing": "יפה! פתרת את המשוואה! ✅"
113
+ },
114
+
115
+ # ========== GENERAL / ALGEBRA ==========
116
+ "GENERAL": {
117
+ "intro": {
118
+ "title": "ניתוח השאלה",
119
+ "content": "בואו נראה מה נתון לנו ומה צריך למצוא. נפרק את הבעיה לשלבים פשוטים.",
120
+ "tip": "קריאה נכונה של השאלה היא 50% מהפתרון!"
121
+ },
122
+ "steps": [
123
+ {
124
+ "title": "מה נתון?",
125
+ "content": "נסדר את הנתונים והמשוואות בצורה ברורה.",
126
+ "uses_llm": "approach" # Use approach/strategy as step 1
127
+ },
128
+ {
129
+ "title": "דרך הפתרון",
130
+ "uses_llm": "steps",
131
+ "tip": "נפתור שלב אחר שלב בצורה מסודרת"
132
+ },
133
+ {
134
+ "title": "תשובה סופית",
135
+ "template": "הגענו לתוצאה: {solution}",
136
+ "uses_llm": "solution"
137
+ }
138
+ ],
139
+ "closing": "מצוין! סיימנו את הסעיף הזה בהצלחה."
140
+ },
141
+
142
+ # Alias for Rational Function (uses General structure but refined)
143
+ "RATIONAL_FUNCTION": {
144
+ "intro": {
145
+ "title": "חקירת פונקציה רציונלית",
146
+ "content": "פונקציה רציונלית היא מנה של פולינומים. נבדוק תחום הגדרה, אסימפטוטות ונקודות מיוחדות.",
147
+ "tip": "חשוב לבדוק מתי המכנה מתאפס!"
148
+ },
149
+ "steps": [
150
+ {
151
+ "title": "ניתוח הפונקציה",
152
+ "content": "נסתכל על המונה והמכנה ונראה אם אפשר לפשט.",
153
+ "uses_llm": "approach"
154
+ },
155
+ {
156
+ "title": "הפתרון המלא",
157
+ "uses_llm": "steps",
158
+ "tip": "עבודה מסודרת מונעת טעויות חישוב"
159
+ },
160
+ {
161
+ "title": "סיכום",
162
+ "uses_llm": "solution"
163
+ }
164
+ ],
165
+ "closing": "כל הכבוד! חקירה יסודית היא המפתח."
166
+ },
167
+
168
+ # ========== TRIGONOMETRY ==========
169
+ "TRIGONOMETRY": {
170
+ "intro": {
171
+ "title": "חשבון טריגונומטרי",
172
+ "content": "נשתמש בזהויות טריגונומטריות ובתכונות המשולש כדי לפתור.",
173
+ "tip": "זכור: sin²x + cos²x = 1"
174
+ },
175
+ "steps": [
176
+ {
177
+ "title": "זיהוי המצב",
178
+ "content": "נבדוק אילו זווית וצלעות נתונות לנו.",
179
+ "uses_llm": "approach"
180
+ },
181
+ {
182
+ "title": "ביצוע החישוב",
183
+ "uses_llm": "steps",
184
+ "tip": "שימו לב יחידות מעלות/רדיאנים!"
185
+ },
186
+ {
187
+ "title": "התשובה",
188
+ "template": "התוצאה: {solution}",
189
+ "uses_llm": "solution"
190
+ }
191
+ ],
192
+ "closing": "מצוין! הטריגונומטריה בידינו! 📐"
193
+ },
194
+ # Alias for basic trig
195
+ "TRIG_BASIC": {
196
+ "intro": { "title": "טריגונומטריה בסיסית", "content": "חישוב זוויות וצלעות במשולש ישר זווית." },
197
+ "steps": [{"title": "פתרון", "uses_llm": "steps"}, {"title": "תשובה", "uses_llm": "solution"}],
198
+ "closing": "יופי!"
199
+ }
200
+ }
201
+
202
+ def build_pedagogical_response(
203
+ topic_id: str,
204
+ llm_output: dict,
205
+ data_anchor: dict,
206
+ custom_title: str = None, # V260.3: Allow override
207
+ proof_graph = None, # V1.1: Immutable ProofGraph
208
+ processing_strategy: ProcessingStrategy = None # V5.8.0: Intent Contract
209
+ ) -> dict:
210
+ """
211
+ V4.2 (Behavioral Firewall): Projection-Only Builder.
212
+ The UI serves ONLY as a projection of the mathematical ProofGraph.
213
+ LLM math generation is strictly forbidden.
214
+ """
215
+ try:
216
+ print(f"🧱 [V4.2] Projection-Only Mode: topic={topic_id}, ProofGraph={proof_graph is not None}")
217
+ print(f"DEBUG [PRE-SCRUB]: LLM generated raw narrative: {llm_output}")
218
+
219
+ if not proof_graph or not proof_graph.steps:
220
+ # V5.8.0: Enforce Intent Matrix! If strategy is STRICT_SYMBOLIC, failure to provide graph is a fatal error.
221
+ if processing_strategy == ProcessingStrategy.STRICT_SYMBOLIC:
222
+ print(f"🛑 [V5.8.0] STRICT_SYMBOLIC Violation: No ProofGraph provided. Blocking response.")
223
+ raise LLMSchemaError("Truth Authority Violation: STRICT_SYMBOLIC strategy requires a verified ProofGraph.")
224
+
225
+ if processing_strategy == ProcessingStrategy.HEURISTIC_DEDUCTION:
226
+ print(f"✅ [V7.3] HEURISTIC_DEDUCTION detected. Bypassing Truth Authority.")
227
+ return _build_generic_response(llm_output, custom_title=custom_title)
228
+
229
+ if isinstance(llm_output, list) and len(llm_output) > 0:
230
+ print(f"✅ [V7.3] Hybrid Navigation detected (List Segment). Bypassing ProofGraph requirement.")
231
+ return _build_generic_response(llm_output, custom_title=custom_title)
232
+
233
+ if isinstance(llm_output, dict) and ("solution_markdown" in llm_output or "steps" in llm_output or "chain_of_thought" in llm_output):
234
+ return _build_generic_response(llm_output, custom_title=custom_title)
235
+
236
+ # V8.5 RESILIENCE: One more attempt to find steps if we're failing
237
+ if isinstance(llm_output, dict) and "sections" in llm_output:
238
+ return _build_generic_response(llm_output, custom_title=custom_title)
239
+
240
+ # If no clues at all, THEN we raise
241
+ logger.warning(f"⚠️ [V8.5] Truth Authority Violation: Falling back to generic due to invalid structure: {llm_output}")
242
+ return _build_generic_response(llm_output, custom_title=custom_title)
243
+
244
+ # 1. Map ProofGraph to Immutable Truth Nodes
245
+ sympy_nodes = []
246
+ for step in proof_graph.steps:
247
+ sympy_nodes.append({
248
+ "step_id": step.step_id,
249
+ "block_math": step.math_content,
250
+ "title": step.logic_description or f"שלב {step.step_id}"
251
+ })
252
+
253
+ # 2. Extract explanations from LLM (The "Skin") - V4.2.7 supports list or dict
254
+ if isinstance(llm_output, list):
255
+ llm_explanations = llm_output
256
+ else:
257
+ # V5.8.2: Support parsing nested 'sections' from the LLM output
258
+ llm_explanations = llm_output.get("steps_explanations", llm_output.get("steps", []))
259
+ if not llm_explanations and "sections" in llm_output:
260
+ for section in llm_output["sections"]:
261
+ if "steps" in section:
262
+ llm_explanations.extend(section["steps"])
263
+
264
+ if not llm_explanations:
265
+ # Internal Fallback: If LLM failed, use generic text to preserve UI
266
+ llm_explanations = [{"step_id": s["step_id"], "explanation_text": "נבצע את החישוב המתמטי"} for s in sympy_nodes]
267
+ else:
268
+ # V276.1: Normalize explanations to handle structured content/type keys
269
+ for node in llm_explanations:
270
+ if "explanation_text" not in node or not node["explanation_text"]:
271
+ node["explanation_text"] = node.get("content_mixed", node.get("content", node.get("explanation", "")))
272
+
273
+ # Unpack dict if still found
274
+ if isinstance(node["explanation_text"], dict):
275
+ node["explanation_text"] = node["explanation_text"].get("content", node["explanation_text"].get("text", str(node["explanation_text"])))
276
+
277
+ # V5.8.2: Layer 2 Runtime Validator (The Kill Switch)
278
+ for node in llm_explanations:
279
+ text = node.get("explanation_text", "")
280
+ if not validate_narrative_density(text):
281
+ print(f"🛑 [V5.8.2] KILL SWITCH TRIGGERED on text: {text}")
282
+ raise LLMSchemaError("NARRATIVE_OVERFLOW: Explanation is too dense or contains forbidden math/English characters.")
283
+
284
+ # 3. Deterministic Merge (Iron Law) - V4.2.7: explanation_text
285
+ merged_steps = merge_and_verify_explanations(sympy_nodes, llm_explanations)
286
+
287
+ # 5. UI Projection (Hard Decoupling V4.2.10)
288
+ ui_steps = []
289
+ for i, node in enumerate(merged_steps):
290
+ # V8.6.2: Ensure LaTeX preserved in content_mixed (removed aggressive $ and \ stripping)
291
+ explanation = sanitize_math_text(node["explanation_text"])
292
+
293
+ math_content = node["block_math"]
294
+
295
+ ui_steps.append({
296
+ "step_id": node["step_id"],
297
+ "step_number": i + 1,
298
+ "explanation_text": explanation,
299
+ "math_artifact": {
300
+ "type": "equation",
301
+ "latex": math_content,
302
+ "table_data": ""
303
+ },
304
+ # We keep these for one more version as 'Ghost Keys' for extreme backward compatibility
305
+ # but they now mirror the structured data perfectly.
306
+ "content_mixed": explanation,
307
+ "block_math": math_content
308
+ })
309
+
310
+ # V8.6: Inject 'approach' as Step 0 to ensure Flutter displays it
311
+ approach = llm_output.get("approach")
312
+ if approach and isinstance(approach, str):
313
+ ui_steps.insert(0, {
314
+ "step_id": 0,
315
+ "step_number": 0,
316
+ "title": "איך ניגשים לזה? 🧭",
317
+ "explanation_text": sanitize_math_text(approach),
318
+ "content_mixed": sanitize_math_text(approach),
319
+ "math_artifact": {"type": "equation", "latex": ""},
320
+ "block_math": ""
321
+ })
322
+
323
+ # V8.6.2: Final check on teacher_summary from LLM
324
+ summary = llm_output.get("teacher_summary") or llm_output.get("summary")
325
+
326
+ response = {
327
+ "sections": [{
328
+ "section_title": custom_title or "פתרון מלא ומדויק",
329
+ "steps": ui_steps,
330
+ "section_result": merged_steps[-1]["block_math"] if merged_steps else ""
331
+ }],
332
+ "final_answer": merged_steps[-1]["block_math"] if merged_steps else "",
333
+ "teacher_closing": llm_output.get("teacher_closing", "כל הכבוד על פתרון התרגיל! 🎉"),
334
+ "approach": approach,
335
+ "teacher_summary": summary
336
+ }
337
+
338
+ # V260.5: Propagate Investigation Data (Crucial for Table UI)
339
+ if "investigation" in llm_output:
340
+ response["investigation"] = llm_output["investigation"]
341
+ elif "investigation_table" in llm_output:
342
+ response["investigation"] = llm_output["investigation_table"]
343
+
344
+ return apply_cognitive_load_limiter(response)
345
+ except Exception as e:
346
+ logger.error(f"🚨 [V8.5 RESILIENCE] Builder Crash: {e}. Falling back to generic.")
347
+ return _build_generic_response(llm_output, custom_title=custom_title)
348
+
349
+ def apply_cognitive_load_limiter(response: dict) -> dict:
350
+ """
351
+ V1.1: Cognitive Load Limiter.
352
+ Ensures steps are revealed gradually.
353
+ """
354
+ if "sections" not in response: return response
355
+
356
+ # Limit to first 2 steps if complex, mark others as 'hidden'
357
+ step_count = 0
358
+ for section in response["sections"]:
359
+ if "steps" in section:
360
+ for step in section["steps"]:
361
+ step_count += 1
362
+ # V1.1 Rule: If more than 3 steps, flag the rest for gradual disclosure
363
+ if step_count > 3:
364
+ step["disclosure_state"] = "HIDDEN"
365
+ else:
366
+ step["disclosure_state"] = "VISIBLE"
367
+
368
+ return response
369
+
370
+ def validate_narrative_density(text: str) -> bool:
371
+ """
372
+ V5.8.2: Layer 2 Runtime Validator (Kill Switch).
373
+ Checks if the pedagogical explanation adheres to the Hard Doctrine.
374
+
375
+ V8.5: RESILIENCE - Relaxed to allow math symbols in text-only steps.
376
+ Returns False ONLY if it is too long (runaway LLM) or contains dangerous code.
377
+ """
378
+ if len(text) > 400:
379
+ return False
380
+ # V8.5: Increased tolerance for English letters (ABC labels) and math signs.
381
+ # We only block forbidden programmatic keywords like 'def', 'class', etc.
382
+ import re
383
+ if re.search(r'\b(import|def|class|lambda)\b', text):
384
+ return False
385
+ return True
386
+
387
+ def merge_and_verify_explanations(sympy_nodes: list[dict], llm_explanations: list[dict]) -> list[dict]:
388
+ """
389
+ V2.5.3: The Swiss Watch Maneuver.
390
+ V3.1.3: Hardened Merge Phase with robust guards.
391
+ V5.8.2: Robust Merge (Option 1) to ignore LLM self-referencing narrative drift.
392
+ Merges Immutable SymPy math (Truth) with LLM explanations (Skin).
393
+ """
394
+ final_nodes = [] # V3.1.3: Mandatory initialization
395
+
396
+ try:
397
+ # V5.8.2 Robust Merge
398
+ for step in sympy_nodes:
399
+ sid = step["step_id"]
400
+
401
+ # Find all LLM explanations for this step
402
+ candidates = [
403
+ s for s in llm_explanations
404
+ if s.get("step_id") == sid and "explanation_text" in s
405
+ ]
406
+
407
+ if not candidates:
408
+ # If LLM completely missed a step, fallback to generic
409
+ final_nodes.append({
410
+ **step,
411
+ "explanation_text": "נבצע את החישוב המתמטי."
412
+ })
413
+ continue
414
+
415
+ # Take the last candidate to ignore preamble/meta-commentary drift
416
+ best_candidate = candidates[-1]
417
+ explanation_text = best_candidate["explanation_text"]
418
+
419
+ # V6 Narrative Drift Telemetry
420
+ if "allowed_concepts" in step and step["allowed_concepts"]:
421
+ import re
422
+ words = [w for w in re.split(r'\s+', explanation_text) if len(w) > 2] # simple word split ignoring short connectives
423
+ allowed_words = set()
424
+ for concept in step["allowed_concepts"]:
425
+ allowed_words.update(concept.split())
426
+
427
+ unauthorized_words = [w for w in words if w not in allowed_words]
428
+ drift_percentage = (len(unauthorized_words) / max(len(words), 1)) * 100
429
+
430
+ if drift_percentage > 50.0:
431
+ import logging
432
+ logger = logging.getLogger(__name__)
433
+ logger.warning(f"⚠️ [V6 TELEMETRY] Drift Warning: {drift_percentage:.1f}% concept drift in Step {sid} (unauthorized: {unauthorized_words[:3]}...)")
434
+
435
+ # V5.8.2 Kill Switch Validator Call
436
+ if not validate_narrative_density(explanation_text):
437
+ msg = f"NARRATIVE_OVERFLOW: Explanation rejected by Kill Switch: {explanation_text[:20]}..."
438
+ print(f"🚨 [V5.8.2] {msg}")
439
+ raise LLMSchemaError("NARRATIVE_OVERFLOW")
440
+
441
+ merged_node = {
442
+ **step,
443
+ "explanation_text": explanation_text
444
+ }
445
+ final_nodes.append(merged_node)
446
+
447
+ return final_nodes
448
+
449
+ except LLMSchemaError:
450
+ raise
451
+ except Exception as e:
452
+ import logging
453
+ logger = logging.getLogger(__name__)
454
+ logger.error(f"🚨 [V3.1.3] Merge Phase Failed: {e}")
455
+ # Since orchestrator is downstream, we re-raise or return something that indicates failure.
456
+ raise LLMSchemaError(f"Merge failure: {str(e)}")
457
+
458
+ def _normalize_llm_keys(llm_output: dict) -> dict:
459
+ """V275.3: Map alternative LLM output keys to expected template keys.
460
+ E.g., CIRCLE_EQUATION returns 'equation' but GENERAL template expects 'solution'."""
461
+ result = dict(llm_output)
462
+ # Map equation -> solution if solution is missing
463
+ if "solution" not in result and "equation" in result:
464
+ result["solution"] = result["equation"]
465
+ # Map approach alternatives
466
+ if "approach" not in result and "strategy" in result:
467
+ result["approach"] = result["strategy"]
468
+ return result
469
+
470
+
471
+ def _build_template_response(topic_id: str, llm_output: dict, data_anchor: dict) -> dict:
472
+ """Build response using topic-specific template."""
473
+
474
+ template = PEDAGOGICAL_TEMPLATES[topic_id]
475
+
476
+ # V275.3: Normalize LLM keys so templates always find what they need
477
+ llm_output = _normalize_llm_keys(llm_output)
478
+
479
+ # Build response
480
+ section_data = {
481
+ "section_title": "פתרון מלא",
482
+ "steps": [],
483
+ "section_result": llm_output.get("equation") or llm_output.get("solution") or llm_output.get("derivative") # V262.0: Per-section result
484
+ }
485
+
486
+ response = {
487
+ "sections": [section_data],
488
+ "final_answer": section_data["section_result"],
489
+ "teacher_closing": template.get("closing", "כל הכבוד! 🎉"),
490
+ "teacher_summary": llm_output.get("teacher_summary") # V262.2: Propagate explicit summary
491
+ }
492
+
493
+ # Add intro step
494
+ if "intro" in template:
495
+ intro = template["intro"]
496
+ response["sections"][0]["steps"].append({
497
+ "step_number": 0,
498
+ "title": intro["title"],
499
+ "content_mixed": intro["content"],
500
+ "teacher_tip": intro.get("tip", "")
501
+ })
502
+
503
+ # Add main steps
504
+ for i, step_template in enumerate(template["steps"], start=1):
505
+
506
+ # V275.2 FIX: Handle unpacking of 'steps' list from llm_output gracefully!
507
+ if step_template.get("uses_llm") == "steps" and isinstance(llm_output.get("steps"), list):
508
+ for s in llm_output["steps"]:
509
+ steps_count = len(response["sections"][0]["steps"])
510
+ # V275.3: Check multiple content key names (different micro-prompts use different schemas)
511
+ content = s.get("content_mixed", s.get("content", s.get("explanation", "")))
512
+ new_step = {
513
+ "step_number": steps_count + 1,
514
+ "title": s.get("title", f"שלב {steps_count + 1}"),
515
+ "content_mixed": sanitize_math_text(content),
516
+ "block_math": s.get("block_math", s.get("result", "")),
517
+ "teacher_tip": s.get("teacher_tip", step_template.get("tip"))
518
+ }
519
+ response["sections"][0]["steps"].append(new_step)
520
+ continue
521
+
522
+ step = {
523
+ "step_number": len(response["sections"][0]["steps"]) + 1,
524
+ "title": step_template["title"]
525
+ }
526
+
527
+ # Fill content
528
+ if "template" in step_template:
529
+ # Template with data substitution (merge with llm_output to prevent KeyError)
530
+ try:
531
+ content = step_template["template"].format(**{**data_anchor, **llm_output})
532
+ except KeyError:
533
+ # V275.3: Strip unresolved {variable} placeholders instead of showing them raw
534
+ content = re.sub(r'\{\w+\}', '', step_template["template"]).strip()
535
+ step["content_mixed"] = content
536
+
537
+ if "content" in step_template:
538
+ step["content_mixed"] = step_template["content"]
539
+
540
+ if "block_math" in step_template:
541
+ step["block_math"] = step_template["block_math"]
542
+
543
+ if "uses_llm" in step_template:
544
+ # Use LLM output
545
+ llm_key = step_template["uses_llm"]
546
+ if llm_key in llm_output:
547
+ if llm_key == "solution" or llm_key == "approach":
548
+ # Solutions and approaches usually have Hebrew, put them in content to prevent flutter crash
549
+ # If there's already template content, append to it
550
+ if step.get("content_mixed"):
551
+ step["content_mixed"] += "\n" + sanitize_math_text(str(llm_output[llm_key]))
552
+ else:
553
+ step["content_mixed"] = sanitize_math_text(str(llm_output[llm_key]))
554
+ else:
555
+ step["block_math"] = sanitize_math_text(str(llm_output[llm_key]))
556
+
557
+ if "tip" in step_template:
558
+ step["teacher_tip"] = step_template["tip"]
559
+
560
+ response["sections"][0]["steps"].append(step)
561
+
562
+ return response
563
+
564
+
565
+
566
+ import gibberish_detector # V231.25: Fix gibberish!
567
+
568
+ def sanitize_math_text(text: str) -> str:
569
+ """
570
+ V231.23: Remove English math artifacts and enforce Hebrew/Latex conventions.
571
+ Forces 'Angle' -> '\\angle', 'Triangle' -> '\\triangle', 'Area' -> 'S'.
572
+ V260.4: Also runs auto_fix_gibberish (reversed Hebrew, broken Latex).
573
+ """
574
+ if not text:
575
+ return text
576
+
577
+ # V260.4: First, fix structural gibberish (reversed Hebrew, broken LaTeX)
578
+ text = gibberish_detector.auto_fix_gibberish(text)
579
+
580
+ # V275.3: Fix quadruple dollars $$$$ -> $$ EARLY (before any block processing)
581
+ text = re.sub(r'\${3,}', '$$', text)
582
+
583
+ # V275.4: CRITICAL - Detect and unwrap $$Hebrew paragraph$$ blocks
584
+ # The LLM wraps Hebrew explanations in $$...$$ which renders as garbled "mirror text"
585
+ # Key insight: Hebrew text mixed with g(x), \ln(x) etc has Hebrew ratio ~30%,
586
+ # so we need a lower threshold AND a consecutive Hebrew words heuristic.
587
+ def _unwrap_hebrew_math_block(match):
588
+ content = match.group(1).strip()
589
+ # Count Hebrew chars vs total
590
+ hebrew_chars = len(re.findall(r'[\u0590-\u05FF]', content))
591
+ total_chars = len(content.replace(' ', ''))
592
+ if total_chars == 0:
593
+ return '' # Empty block, remove it
594
+ hebrew_ratio = hebrew_chars / total_chars
595
+ # Heuristic 1: >25% Hebrew with enough chars = text paragraph
596
+ if hebrew_ratio > 0.25 and hebrew_chars > 8:
597
+ print(f"🧹 [SANITIZE] Unwrapped Hebrew-in-math block ({hebrew_chars} Hebrew chars, ratio={hebrew_ratio:.1%})")
598
+ return content # Return as plain text without $$
599
+ # Heuristic 2: Has 3+ consecutive Hebrew words (even if ratio is low)
600
+ if re.search(r'[\u0590-\u05FF]+\s+[\u0590-\u05FF]+\s+[\u0590-\u05FF]+', content):
601
+ print(f"🧹 [SANITIZE] Unwrapped Hebrew-in-math (consecutive Hebrew words detected)")
602
+ return content
603
+ return match.group(0) # Keep as-is for real math
604
+
605
+ text = re.sub(r'\$\$(.+?)\$\$', _unwrap_hebrew_math_block, text, flags=re.DOTALL)
606
+
607
+ # V231.25: Fix corrupted LaTeX escapes (form feed \f, invalid \3)
608
+ text = text.replace('\x0c', r'\f')
609
+ text = re.sub(r'\\(\d)', r'\1', text)
610
+
611
+ # V275.2: Fix double backslash newlines inside $$...$$ which crash flutter_math_fork
612
+ # Split blocks safely instead of using \newline
613
+ def safe_split_newlines(match):
614
+ block = match.group(1)
615
+ if r'\begin{' in block:
616
+ # Leave environments like \begin{cases} alone, they support \\
617
+ return match.group(0)
618
+ # Split by \\ or \newline
619
+ parts = re.split(r'\\\\|\\newline', block)
620
+ # V280.3: Ensure we don't strip the outer $$ markers when splitting blocks!
621
+ joined = '$$\n$$'.join(p.strip() for p in parts if p.strip())
622
+ return f"$${joined}$$" if joined else ""
623
+
624
+ text = re.sub(r'\$\$(.+?)\$\$', safe_split_newlines, text, flags=re.DOTALL)
625
+
626
+ # 1. English Geometrical terms (Case Insensitive)
627
+ # \\b matches word boundaries to avoid replacing substrings
628
+ text = re.sub(r'\\bAngle\\b', r'\\angle', text, flags=re.IGNORECASE)
629
+ text = re.sub(r'\\bTriangle\\b', r'\\triangle', text, flags=re.IGNORECASE)
630
+ text = re.sub(r'\\bDeg\\b', r'^{\\circ}', text, flags=re.IGNORECASE)
631
+
632
+ # 2. "Area" -> S (e.g. "Area of triangle" -> "S of triangle")
633
+ # Be careful not to replace valid words, but "Area" in math context is usually S
634
+ text = re.sub(r'\\bArea\\b', r'S', text, flags=re.IGNORECASE)
635
+
636
+ # V262.1: Auto-wrap Hebrew inside LaTeX blocks (The "Escaping Lines" Fix)
637
+ text = _auto_wrap_hebrew_in_latex(text)
638
+
639
+ return text
640
+
641
+ def _auto_wrap_hebrew_in_latex(text: str) -> str:
642
+ """
643
+ Scans for Hebrew characters inside $$...$$ or $...$ blocks.
644
+ If found, wraps them in \\text{...} to prevent rendering crashes.
645
+ """
646
+ if not text: return text
647
+
648
+ # Regex for Hebrew chars (including nikud/punctuation common in Hebrew)
649
+ hebrew_pattern = r'([\u0590-\u05FF\s\.\,\:\-]+)'
650
+
651
+ def replacer(match):
652
+ content = match.group(1) # The content inside the dollars
653
+ # Check if there is Hebrew in this block
654
+ if re.search(r'[\u0590-\u05FF]', content):
655
+ # There is Hebrew! Let's wrap the Hebrew parts in \text{...}
656
+ # We split by math/hebrew chunks or just wrap the whole Hebrew phrase
657
+ # Simple approach: Find Hebrew chunks and wrap them
658
+ new_content = re.sub(hebrew_pattern, r'\\text{\1}', content)
659
+ # Cleanup: \text{ } (empty) or double wrapping check could be added if needed
660
+ return f"${new_content}$"
661
+ return match.group(0)
662
+
663
+ # Replace inline math $...$ (using naive non-nested check)
664
+ # We use a trick to avoid matching $$...$$ first if we aren't careful,
665
+ # but specific regex for $$...$$ should come first if we supported it fully as separate.
666
+ # For now, let's handle $...$ which often covers $$...$$ in simple regex unless distinct.
667
+ # Actually, $$ is just two $s. Let's try to be safe.
668
+
669
+ # Strategy: Split by '$' and process every odd element (1, 3, 5...) as Math?
670
+ # This is safer than regex for nested/complex strings.
671
+
672
+ parts = text.split('$')
673
+ if len(parts) < 3: return text # No math blocks
674
+
675
+ new_parts = []
676
+ for i, part in enumerate(parts):
677
+ if i % 2 == 1: # This is a MATH block (inside $...$)
678
+ if re.search(r'[\u0590-\u05FF]', part):
679
+ # Found Hebrew inside Math! Wrap it.
680
+ # Note: We must be careful not to wrap existing \text{...} again if possible,
681
+ # but simple wrapping usually doesn't hurt: \text{\text{...}} is valid-ish or we can ignore.
682
+
683
+ # Better: only wrap Hebrew that is NOT already in \text{...}?
684
+ # That's complex. Let's do the simple "Wrap Hebrew Chars" regex.
685
+ # We exclude commands commands like \frac, \cdot etc.
686
+
687
+ def wrap_hebrew(m):
688
+ s = m.group(1)
689
+ if len(s.strip()) == 0: return s # Don't wrap just whitespace
690
+ if '\\text' in s: return s # Already wrapped (naive check)
691
+ return f"\\text{{{s}}}"
692
+
693
+ # Apply wrapping to identified hebrew chunks
694
+ # Note: we use a simplified version of the regex for local substitution
695
+ part = re.sub(hebrew_pattern, wrap_hebrew, part)
696
+
697
+ new_parts.append(part)
698
+ else:
699
+ # This is a REGULAR block (outside/between $...$)
700
+ new_parts.append(part)
701
+
702
+ return '$'.join(new_parts)
703
+
704
+ def _build_generic_response(llm_output: dict, custom_title: str = None) -> dict:
705
+ """
706
+ V231.20: Rich UI formatter — restores 'Old Look' with green box and mixed text/math.
707
+ ...
708
+ """
709
+ # Extract steps
710
+ steps = []
711
+
712
+ # helper for clean content
713
+ def get_content(s):
714
+ if isinstance(s, dict):
715
+ # Check multiple content key names (different micro-prompts use different schemas)
716
+ content = s.get("content_mixed", s.get("content", s.get("explanation", s.get("explanation_text", ""))))
717
+
718
+ # V275.5: If content is still a dict (e.g. from structured JSON fragments), extract text
719
+ if isinstance(content, dict):
720
+ content = content.get("text", content.get("content", str(content)))
721
+ return content
722
+ return str(s)
723
+
724
+ # V4.3 Unified Markdown Path
725
+ if isinstance(llm_output, dict) and "solution_markdown" in llm_output:
726
+ steps.append({
727
+ "step_id": 1,
728
+ "step_number": 1,
729
+ "title": "פתרון מלא",
730
+ "explanation_text": sanitize_math_text(str(llm_output["solution_markdown"])),
731
+ "content_mixed": sanitize_math_text(str(llm_output["solution_markdown"])),
732
+ "math_artifact": {"type": "equation", "latex": ""},
733
+ "is_unified_markdown": True
734
+ })
735
+ elif (isinstance(llm_output, list)) or (isinstance(llm_output, dict) and "steps" in llm_output and isinstance(llm_output["steps"], list)):
736
+ raw_steps = llm_output if isinstance(llm_output, list) else llm_output["steps"]
737
+ for i, s in enumerate(raw_steps, 1):
738
+ content = get_content(s)
739
+ if isinstance(content, str):
740
+ content = sanitize_math_text(content)
741
+
742
+ math_latex = ""
743
+ if isinstance(s, dict):
744
+ math_latex = s.get("math_latex", s.get("block_math", s.get("result", "")))
745
+
746
+ steps.append({
747
+ "step_id": s.get("step_id", i) if isinstance(s, dict) else i,
748
+ "step_number": i,
749
+ "title": s.get("title", f"שלב {i}") if isinstance(s, dict) else f"שלב {i}",
750
+ "explanation_text": content,
751
+ "content_mixed": content,
752
+ "math_artifact": {
753
+ "type": "equation",
754
+ "latex": math_latex
755
+ },
756
+ "block_math": math_latex,
757
+ "teacher_tip": s.get("teacher_tip") if isinstance(s, dict) else None
758
+ })
759
+ elif isinstance(llm_output, dict) and "chain_of_thought" in llm_output:
760
+ # Fallback for old models
761
+ cot = sanitize_math_text(str(llm_output["chain_of_thought"]))
762
+ steps.append({
763
+ "step_id": 1,
764
+ "step_number": 1,
765
+ "title": "דרך הפתרון",
766
+ "explanation_text": cot,
767
+ "content_mixed": cot,
768
+ "math_artifact": {"type": "equation", "latex": ""}
769
+ })
770
+ else:
771
+ # Last resort - use get_content helper to handle single dict blocks correctly
772
+ content = get_content(llm_output)
773
+ if isinstance(content, str):
774
+ content = sanitize_math_text(content)
775
+
776
+ steps.append({
777
+ "step_id": 1,
778
+ "step_number": 1,
779
+ "title": "הפתרון",
780
+ "explanation_text": content,
781
+ "content_mixed": content,
782
+ "math_artifact": {"type": "equation", "latex": ""}
783
+ })
784
+
785
+ # Extract final answer
786
+ # Extract final answer with improved fallback logic (V261.16)
787
+ final_answer = (
788
+ llm_output.get("final_answer") or
789
+ llm_output.get("equation") or
790
+ llm_output.get("solution") or
791
+ llm_output.get("derivative") or
792
+ llm_output.get("integral") or
793
+ llm_output.get("limit") or
794
+ llm_output.get("x_intercepts") or
795
+ llm_output.get("min_max_points")
796
+ )
797
+
798
+ # If it's a list or dict (e.g. points), convert to string representation
799
+ if isinstance(final_answer, (list, dict)):
800
+ final_answer = str(final_answer)
801
+
802
+ if not final_answer:
803
+ final_answer = "ראה שלבים"
804
+
805
+ # V8.6: Inject 'approach' as Step 0
806
+ approach = llm_output.get("approach")
807
+ if approach and isinstance(approach, str):
808
+ steps.insert(0, {
809
+ "step_id": 0,
810
+ "step_number": 0,
811
+ "title": "איך ניגשים לזה? 🧭",
812
+ "explanation_text": sanitize_math_text(approach),
813
+ "content_mixed": sanitize_math_text(approach),
814
+ "math_artifact": {"type": "equation", "latex": ""},
815
+ "block_math": ""
816
+ })
817
+
818
+ response_obj = {
819
+ "sections": [{
820
+ "section_title": custom_title or "הפתרון",
821
+ "steps": steps,
822
+ "section_result": str(final_answer) # V262.0: Per-section result
823
+ }],
824
+ "final_answer": str(final_answer),
825
+ "teacher_closing": llm_output.get("teacher_closing", "כל הכבוד! 🎉"),
826
+ "approach": approach, # V8.6: Explicit approach field
827
+ "teacher_summary": llm_output.get("teacher_summary") # V262.2: Propagate explicit summary
828
+ }
829
+
830
+ # V260.5: Propagate Investigation Data (Crucial for Table UI)
831
+ if "investigation" in llm_output:
832
+ response_obj["investigation"] = llm_output["investigation"]
833
+ elif "investigation_table" in llm_output:
834
+ response_obj["investigation"] = llm_output["investigation_table"]
835
+
836
+ return response_obj
837
+
838
+ if __name__ == "__main__":
839
+ import json
840
+
841
+ # Test circle equation
842
+ llm_out = {
843
+ "equation": "(x-3)^2 + (y-5)^2 = 25",
844
+ "center": [3, 5],
845
+ "radius": 5
846
+ }
847
+ data = {"center": "(3,5)", "radius": 5}
848
+
849
+ response = build_pedagogical_response("CIRCLE_EQUATION", llm_out, data)
850
+ print(json.dumps(response, indent=2, ensure_ascii=False))
problem_understanding.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # problem_understanding.py - V231.14
2
+ # Analyzes problem structure BEFORE solving
3
+
4
+ """
5
+ Problem Understanding Module
6
+ Ensures we understand WHAT is being asked before attempting to solve.
7
+ """
8
+
9
+ import json
10
+ import re
11
+ from utils.safe_json import safe_extract_json # V1.0: Canonical extractor
12
+
13
+
14
+ def get_problem_understanding_prompt(ocr_text: str, data_anchor: dict) -> str:
15
+ """
16
+ Prompt LLM to analyze problem structure.
17
+
18
+ Returns JSON with:
19
+ - problem_type
20
+ - sub_questions (all parts א, ב, ג, etc.)
21
+ - solving_order
22
+ - dependencies
23
+ """
24
+ return f"""
25
+ ANALYZE this math problem structure. DO NOT SOLVE - only understand what is being asked.
26
+
27
+ Problem Text:
28
+ {ocr_text}
29
+
30
+ Extracted Data:
31
+ {json.dumps(data_anchor, ensure_ascii=False, indent=2)}
32
+
33
+ Your task: Identify ALL parts of this problem and create a solving plan.
34
+
35
+ Return JSON:
36
+ {{
37
+ "problem_type": "CIRCLE_EQUATION | LINE_EQUATION | GEOMETRIC_LOCUS | DERIVATIVE_QUOTIENT | etc.",
38
+ "main_question": "Brief description of main question",
39
+ "sub_questions": [
40
+ {{
41
+ "id": "א",
42
+ "question": "Full text of sub-question א",
43
+ "requires": ["center", "radius"],
44
+ "expected_output": "equation | number | point | etc.",
45
+ "topic": "CIRCLE_EQUATION"
46
+ }},
47
+ {{
48
+ "id": "ב",
49
+ "question": "Full text of sub-question ב",
50
+ "requires": ["equation_from_א", "point"],
51
+ "expected_output": "line_equation",
52
+ "topic": "LINE_TANGENT"
53
+ }}
54
+ ],
55
+ "solving_order": ["א", "ב", "ג"],
56
+ "dependencies": {{
57
+ "ב": ["א"],
58
+ "ג": ["א"]
59
+ }}
60
+ }}
61
+
62
+ CRITICAL RULES:
63
+ 1. Include ALL sub-questions (א, ב, ג, ד, etc.)
64
+ 2. **EXCEPTION:** If the problem asks for a **Geometric Locus (מקום גיאומטרי)**:
65
+ - This is a SINGLE QUESTION (even if it looks long).
66
+ - Set `problem_type` = "GEOMETRIC_LOCUS".
67
+ - Create ONLY ONE sub-question (id="א") containing the entire text.
68
+ 3. Identify dependencies (ב needs א's result)
69
+ 4. Determine topic for EACH sub-question
70
+ 5. DO NOT solve - only analyze structure
71
+
72
+ Return ONLY valid JSON.
73
+ """
74
+
75
+
76
+ def parse_understanding(response_text: str) -> dict:
77
+ """Parse LLM understanding response using canonical safe_extract_json."""
78
+ result = safe_extract_json(response_text, caller="PROBLEM_UNDERSTANDING")
79
+ # If parsing failed, return a minimal fallback so the pipeline continues
80
+ if isinstance(result, dict) and result.get("logic_error"):
81
+ return {
82
+ "problem_type": "UNKNOWN",
83
+ "sub_questions": [],
84
+ "solving_order": [],
85
+ "dependencies": {}
86
+ }
87
+ return result
88
+
89
+
90
+
91
+ def validate_understanding(understanding: dict) -> bool:
92
+ """Validate understanding structure."""
93
+ required_keys = ["problem_type", "sub_questions", "solving_order"]
94
+
95
+ if not all(key in understanding for key in required_keys):
96
+ return False
97
+
98
+ if not isinstance(understanding["sub_questions"], list):
99
+ return False
100
+
101
+ if len(understanding["sub_questions"]) == 0:
102
+ return False
103
+
104
+ # Validate each sub-question
105
+ for sq in understanding["sub_questions"]:
106
+ if not all(key in sq for key in ["id", "question", "topic"]):
107
+ return False
108
+
109
+ return True
110
+
111
+ def enforce_locus_rule(understanding: dict, ocr_text: str) -> dict:
112
+ """
113
+ V260.2: Hard Rule - If 'מקום גיאומטרי' exists, FORCE Locus type.
114
+ """
115
+ if any(k in ocr_text for k in ["מקום גיאומטרי", "Locus", "מצא את המקום", "המקום הגיאומטרי"]):
116
+ print("🛡️ [BIT-LOG] Hard Logic: Detected 'Geometric Locus' - Forcing Single Question structure.")
117
+ return {
118
+ "problem_type": "GEOMETRIC_LOCUS",
119
+ "main_question": understanding.get("main_question", "Find the Locus"),
120
+ "sub_questions": [{
121
+ "id": "א",
122
+ "question": ocr_text, # Give the WHOLE text to the single sub-question
123
+ "requires": [],
124
+ "expected_output": "equation",
125
+ "topic": "GEOMETRIC_LOCUS"
126
+ }],
127
+ "solving_order": ["א"],
128
+ "dependencies": {}
129
+ }
130
+ return understanding
131
+
132
+
133
+ # ==================== USAGE EXAMPLE ====================
134
+
135
+ if __name__ == "__main__":
136
+ # Test understanding prompt
137
+ ocr = """
138
+ מעגל עם משוואה x² + y² = 12
139
+ א. מצא את משוואת המעגל
140
+ ב. מצא משיק למעגל בנקודה A
141
+ ג. מצא את שטח המעגל
142
+ """
143
+
144
+ data = {
145
+ "function_equations": ["x^2 + y^2 = 12"],
146
+ "points": ["A"],
147
+ "specific_values": [],
148
+ "constraints": []
149
+ }
150
+
151
+ prompt = get_problem_understanding_prompt(ocr, data)
152
+ print(prompt)
prompts.py ADDED
@@ -0,0 +1,815 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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"⚠️ הנחיה קריטית: אל תנחש את הצורה! פתח את המשוואה צעד-אחר-צעד מההגדרה (למשל d1=d2).")
55
+ rules.append(r"בביטויים עם שורשים: בודד שורש אחד -> עלה בריבוע -> בודד את השורש השני -> עלה בריבוע שוב.")
56
+
57
+ # === PROOF-specific rules (V282.0) ===
58
+ if any(x in t for x in ["הוכח", "הוכיח", "הוכיחו", "הראה כי", "הראי כי", "הוכח כי", "הוכיחי"]):
59
+ rules.append(r"⚠️ זוהי שאלת הוכחה! חובה להראות את כל שרשרת ההיגיון, לא רק את התוצאה.")
60
+ rules.append(r"מבנה הוכחה: נתון -> מסקנה ביניים (+ נימוק/משפט) -> מסקנה הבאה -> ... -> מ.ש.ל.")
61
+ rules.append(r"לכל מעבר לוגי חובה לציין את הנימוק: שם המשפט, התכונה, או הכלל.")
62
+ rules.append(r"משולש שווה שוקיים -> זוויות בסיס שוות, חוצה זווית = תיכון = גובה לבסיס.")
63
+ rules.append(r"משולשים חופפים: צ.צ.צ / צ.ז.צ / ז.צ.ז - ציין איזה קריטריון נבחר ולמה.")
64
+ rules.append(r"אם צריך להוכיח שוויון קטעים - חפש משולשים חופפים, או תכונות של צורות מיוחדות.")
65
+
66
+ rules.append(r"בגיאומטריה אנליטית: לפני חישוב, בצע 'בדיקת שפיות' (Sanity Check).")
67
+ rules.append(r"אם נתון ש-AC קוטר, המרכז M *חייב* להיות האמצע שלו. אם החישוב מראה א��רת - הנתונים שהוצאת שגויים! נסה לקרוא שוב.")
68
+ rules.append(r"עדיפות לנתונים: טקסט כתוב > משוואות > שרטוט ויזואלי.")
69
+ rules.append(r"הימנע משימוש ב-\\\\ בתוך בלוקים של מתמטיקה, השתמש בצעדים נפרדים.")
70
+
71
+ # V8.6.3: Contextual Logic Guardrails
72
+ if category == "GEOMETRY":
73
+ rules.append(r"CRITICAL GEOMETRY RULE: NEVER invent or alter the position of points, lines, or intersections. You MUST strictly adhere to the geometric layout described in the problem text. If your calculation leads to a paradox (e.g., length 0 or negative area), YOUR geometric assumption is wrong. Recalculate based strictly on the given text constraints.")
74
+
75
+ if category in ["CALCULUS", "FUNCTION_ANALYSIS", "INVESTIGATION"]:
76
+ rules.append(r"GRAPH IDENTIFICATION RULE: To match a function to its graph, rely STRICTLY on mathematical anchors you calculated (e.g., axis intersections, positive/negative domains, extrema). Deduce the correct graph logically. DO NOT blindly trust the visual labels (I, II, 1, 2) from the image context, as they might be read incorrectly by the OCR.")
77
+
78
+ if category == "CALCULUS":
79
+ if any(x in t for x in ["נגזרת", "גזור", "f'", "חקור"]):
80
+ rules.append(r"כלל החזקה: $(x^n)' = nx^{n-1}$")
81
+ if any(x in t for x in ["/", "frac", "מנה", "חילוק"]):
82
+ rules.append(r"נגזרת מנה: $(\\frac{u}{v})' = \\frac{u'v - uv'}{v^2}$")
83
+ if any(x in t for x in ["מכפלה", "כפל"]):
84
+ rules.append(r"נגזרת מכפלה: $(u \\cdot v)' = u'v + uv'$")
85
+ if any(x in t for x in ["שרשרת", "הרכבה", "sin", "cos"]):
86
+ rules.append(r"כלל שרשרת: $[f(g(x))]' = f'(g(x)) \\cdot g'(x)$")
87
+ if any(x in t for x in ["אינטגרל", "שטח מתחת"]):
88
+ rules.append(r"אינטגרל חזקה: $\\int x^n dx = \\frac{x^{n+1}}{n+1} + C$")
89
+ if any(x in t for x in ["דיפרנציאלית", "y'", "dy/dx"]):
90
+ rules.append(r"משוואה דיפרנציאלית: הפרד משתנים, אינטגרל משני הצדדים.")
91
+
92
+ # === SEQUENCES rules ===
93
+ if any(x in t for x in ["סדרה", "חשבונית", "הנדסית", "סכום חלקי", "הפרש"]):
94
+ rules.append(r"סדרה חשבונית: $a_n = a_1 + (n-1)d$, סכום: $S_n = \\frac{n}{2}(a_1 + a_n)$")
95
+ rules.append(r"סדרה הנדסית: $a_n = a_1 \\cdot q^{n-1}$, סכום: $S_n = a_1 \\cdot \\frac{q^n - 1}{q - 1}$")
96
+ if "אינסופי" in t or "גבול" in t:
97
+ rules.append(r"סכום סדרה הנדסית אינסופית ($|q|<1$): $S = \\frac{a_1}{1-q}$")
98
+
99
+ # === COMPLEX NUMBERS rules ===
100
+ if any(x in t for x in ["מרוכב", "מדומה", "i²", "i^2"]):
101
+ rules.append(r"$i^2 = -1$, מספר מרוכב: $z = a + bi$")
102
+ rules.append(r"מודולוס: $|z| = \\sqrt{a^2 + b^2}$, צמוד: $\\bar{z} = a - bi$")
103
+ if any(x in t for x in ["קוטבי", "דה-מואבר", "טריגונומטרי"]):
104
+ rules.append(r"צורה טריגונומטרית: $z = r(\\cos\\theta + i\\sin\\theta)$")
105
+ rules.append(r"דה-מואבר: $z^n = r^n(\\cos(n\\theta) + i\\sin(n\\theta))$")
106
+
107
+ # === VECTORS rules ===
108
+ if any(x in t for x in ["וקטור", "וקטורים"]):
109
+ rules.append(r"$\\vec{u} = (a,b)$, $|\\vec{u}| = \\sqrt{a^2+b^2}$")
110
+ rules.append(r"מכפלה סקלרית: $\\vec{u}\\cdot\\vec{v} = a_1 a_2 + b_1 b_2 = |u||v|\\cos\\alpha$")
111
+ rules.append(r"וקטורים מאונכים: $\\vec{u}\\cdot\\vec{v} = 0$")
112
+ # V286.0: 3D Vectors (5 יח"ל)
113
+ if any(x in t for x in ["מרחב", "מישור", "תלת", "z", "פרמטרי", "ניצב למישור"]):
114
+ rules.append(r"וקטור במרחב: $\\vec{u} = (a,b,c)$, $|\\vec{u}| = \\sqrt{a^2+b^2+c^2}$")
115
+ rules.append(r"מכפלה סקלרית 3D: $\\vec{u}\\cdot\\vec{v} = a_1 a_2 + b_1 b_2 + c_1 c_2$")
116
+ rules.append(r"מכפלה וקטורית: $\\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)$")
117
+ rules.append(r"משוואת מישור: $Ax + By + Cz + D = 0$ כאשר $(A,B,C)$ הוא וקטור נורמלי למישור.")
118
+ rules.append(r"ישר במרחב (הצגה פרמטרית): $\\vec{r} = \\vec{p} + t\\vec{v}$ כלומר $x=p_1+tv_1, y=p_2+tv_2, z=p_3+tv_3$")
119
+ 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}}$")
120
+ rules.append(r"זווית בין שני מישורים: $\\cos\\alpha = \\frac{|\\vec{n_1}\\cdot\\vec{n_2}|}{|\\vec{n_1}||\\vec{n_2}|}$ (נורמלים)")
121
+ rules.append(r"זווית בין ישר לבין מישור: $\\sin\\alpha = \\frac{|\\vec{v}\\cdot\\vec{n}|}{|\\vec{v}||\\vec{n}|}$")
122
+ rules.append(r"שני ישרים מקבילים אם $\\vec{v_1} = k\\vec{v_2}$. מישורים מקבילים אם $\\vec{n_1} = k\\vec{n_2}$.")
123
+ rules.append(r"מכפלה משולשת (נפח מקבילון): $V = |\\vec{a}\\cdot(\\vec{b}\\times\\vec{c})|$")
124
+
125
+ # === STATISTICS rules ===
126
+ if any(x in t for x in ["ממוצע", "חציון", "שכיח", "סטיית תקן", "שונות"]):
127
+ rules.append(r"ממוצע: $\\bar{x} = \\frac{\\sum x_i}{n}$")
128
+ rules.append(r"שונות: $\\sigma^2 = \\frac{\\sum(x_i - \\bar{x})^2}{n}$, סטיית תקן: $\\sigma = \\sqrt{\\sigma^2}$")
129
+ if any(x in t for x in ["התפלגות", "נורמלית", "בינומית"]):
130
+ rules.append(r"התפלגות בינומית: $P(X=k) = \\binom{n}{k}p^k(1-p)^{n-k}$")
131
+ rules.append(r"התפלגות נורמלית: כלל 68-95-99.7 (אחוזים בטווח 1-2-3 סטיות תקן)")
132
+
133
+ # === INDUCTION rules ===
134
+ if any(x in t for x in ["אינדוקציה", "הוכח כי לכל", "n טבעי"]):
135
+ rules.append(r"שלב 1 (בסיס): הוכח עבור $n=1$. שלב 2 (הנחה): הנח עבור $n=k$. שלב 3 (צעד): הוכח עבור $n=k+1$.")
136
+ rules.append(r"⚠️ חובה לרשום בפירוש: 'מה צריך להוכיח', 'הנחת האינדוקציה', 'מ.ש.ל'.")
137
+
138
+ # === TRIGONOMETRY LAW rules ===
139
+ if any(x in t for x in ["סינוסים", "קוסינוסים", "משולש", "זווית"]):
140
+ if "סינוסים" in t:
141
+ rules.append(r"משפט הסינוסים: $\\frac{a}{\\sin A} = \\frac{b}{\\sin B} = \\frac{c}{\\sin C}$")
142
+ if "קוסינוסים" in t:
143
+ rules.append(r"משפט הקוסינוסים: $c^2 = a^2 + b^2 - 2ab\\cos C$")
144
+ rules.append(r"שטח משולש לפי שתי צלעות וזווית: $S = \\frac{1}{2}ab\\sin C$")
145
+ if any(x in t for x in ["זהות", "טריגונומטרית"]):
146
+ rules.append(r"$\\sin^2(x) + \\cos^2(x) = 1$")
147
+ rules.append(r"$\\sin(2x) = 2\\sin(x)\\cos(x)$, $\\cos(2x) = \\cos^2(x) - \\sin^2(x)$")
148
+ # V286.0: Advanced Trigonometry (5 יח"ל)
149
+ if any(x in t for x in ["sin", "cos", "tan", "trig", "טריגונומטר", "sin(", "cos("]):
150
+ if any(x in t for x in ["סכום", "הפרש", "α+β", "α-β", "alpha"]):
151
+ rules.append(r"$\\sin(\\alpha \\pm \\beta) = \\sin\\alpha\\cos\\beta \\pm \\cos\\alpha\\sin\\beta$")
152
+ rules.append(r"$\\cos(\\alpha \\pm \\beta) = \\cos\\alpha\\cos\\beta \\mp \\sin\\alpha\\sin\\beta$")
153
+ rules.append(r"$\\tan(\\alpha + \\beta) = \\frac{\\tan\\alpha + \\tan\\beta}{1 - \\tan\\alpha\\tan\\beta}$")
154
+ if any(x in t for x in ["חצי זווית", "sin²", "cos²"]):
155
+ rules.append(r"$\\sin^2\\frac{x}{2} = \\frac{1-\\cos x}{2}$, $\\cos^2\\frac{x}{2} = \\frac{1+\\cos x}{2}$")
156
+ if any(x in t for x in ["פתור משוואה טריגונומטרית", "sinx=", "cosx=", "sin x =", "cos x ="]):
157
+ rules.append(r"$\\sin x = a \\Rightarrow x = (-1)^n \\arcsin a + \\pi n$, $n \\in \\mathbb{Z}$")
158
+ rules.append(r"$\\cos x = a \\Rightarrow x = \\pm \\arccos a + 2\\pi n$, $n \\in \\mathbb{Z}$")
159
+ rules.append(r"$\\tan x = a \\Rightarrow x = \\arctan a + \\pi n$, $n \\in \\mathbb{Z}$")
160
+
161
+ # === VOLUME & SURFACE AREA rules ===
162
+ if any(x in t for x in ["נפח", "שטח פנים", "גליל", "חרוט", "כדור"]):
163
+ rules.append(r"נפח גליל: $V = \\pi r^2 h$, נפח חרוט: $V = \\frac{1}{3}\\pi r^2 h$")
164
+ rules.append(r"נפח כדור: $V = \\frac{4}{3}\\pi r^3$, שטח פנים כדור: $S = 4\\pi r^2$")
165
+
166
+ # V286.0: Solid Geometry (גיאומטריה מרחבית — 5 יח"ל)
167
+ if any(x in t for x in ["פירמידה", "מנסרה", "תיבה", "חתך", "אפותם", "מקצוע צדדי", "פאות"]):
168
+ rules.append(r"נפח פירמידה: $V = \\frac{1}{3} S_{base} \\cdot h$")
169
+ rules.append(r"נפח מנסרה: $V = S_{base} \\cdot h$")
170
+ rules.append(r"⚠️ אסטרטגיה: זהה את המשולש ישר-הזווית החבוי בתוך הגוף המרחבי (בד\"כ בין גובה, מקצוע צדדי, אפותם).")
171
+ rules.append(r"זווית בין מקצוע צדדי לבסיס: מצא משולש ישר-זווית שמכיל את הגובה ואת ההטל של המקצוע על הבסיס.")
172
+ rules.append(r"זווית בין פאה צדדית לבסיס: מצא את האפותם של הפאה ואת גובה הפירמידה. $\\tan\\alpha = \\frac{h}{apothem}$")
173
+ rules.append(r"שטח פנים כולל = שטח בסיס + סכום שטחי הפאות הצדדיות.")
174
+ rules.append(r"בפירמידה משוכללת: כל המקצועות הצדדיים שווים, וכל משולשי הפאות הצדדיות חופפים.")
175
+
176
+ # === INEQUALITY rules ===
177
+ if any(x in t for x in ["אי-שוויון", "אי שוויון"]):
178
+ rules.append(r"⚠️ בכפל/חילוק באי-שוויון במספר שלילי — הפוך את כיוון הסימן!")
179
+ if "ריבועי" in t:
180
+ rules.append(r"אי-שוויון ריבועי: פתור כמשוואה, בדוק סימן בקטעים.")
181
+
182
+ # V286.0: Logarithms & Exponentials (לוגריתמים — 5 יח"ל)
183
+ if any(x in t for x in ["לוגריתם", "log", "ln", "מעריכי", "e^"]):
184
+ rules.append(r"$\\log_a(xy) = \\log_a x + \\log_a y$")
185
+ rules.append(r"$\\log_a\\left(\\frac{x}{y}\\right) = \\log_a x - \\log_a y$")
186
+ rules.append(r"$\\log_a(x^n) = n\\log_a x$")
187
+ rules.append(r"החלפת בסיס: $\\log_a b = \\frac{\\ln b}{\\ln a}$")
188
+ rules.append(r"$a^x = e^{x\\ln a}$, $\\log_a a = 1$, $\\log_a 1 = 0$")
189
+ rules.append(r"$\\ln e = 1$, $e^{\\ln x} = x$, $\\ln(e^x) = x$")
190
+
191
+ # V286.0: Optimization Problems (בעיות קיצון — 5 יח"ל)
192
+ if any(x in t for x in ["קיצון", "מקסימום", "מינימום", "שטח גדול ביותר", "נפח מקסימלי", "ערך מרבי", "ערך מזערי", "מקסימלי", "מינימלי"]):
193
+ rules.append(r"⚠️ אסטרטגיית בעיית קיצון (מלל): 1) הגדר משתנה. 2) בנה את פונקציית המטרה $f(x)$. 3) אם יש אילוץ — בטא משתנה אחד בעזרת האחר. 4) גזור ושווה ל-0. 5) ודא מקסימום/מינימום ע\"י נגזרת שנייה. 6) הצב חזרה לתשובה.")
194
+ rules.append(r"שיטת הקצוות: בקטע סגור $[a,b]$, בדוק גם ב-$f(a)$, $f(b)$ וגם בנקודות קריטיות.")
195
+ rules.append(r"סיווג נקודת קיצון: $f''(x_0) > 0$ ⟹ מינימום, $f''(x_0) < 0$ ⟹ מקסימום.")
196
+
197
+ # V286.0: Probability & Combinatorics (הסתברות וקומבינטוריקה — 5 יח"ל)
198
+ if any(x in t for x in ["הסתברות", "קומבינטור", "עצי", "עץ", "תמורה", "צירוף", "בייס", "מותנה", "בלתי תלוי"]):
199
+ rules.append(r"$P(A \\cup B) = P(A) + P(B) - P(A \\cap B)$")
200
+ rules.append(r"הסתברות מותנית: $P(A|B) = \\frac{P(A \\cap B)}{P(B)}$")
201
+ rules.append(r"נוסחת בייס: $P(B_i|A) = \\frac{P(A|B_i)P(B_i)}{\\sum_j P(A|B_j)P(B_j)}$")
202
+ rules.append(r"חוק ההסתברות השלמה: $P(A) = \\sum_i P(A|B_i)P(B_i)$")
203
+ rules.append(r"תמורות: $P(n,k) = \\frac{n!}{(n-k)!}$, צירופים: $\\binom{n}{k} = \\frac{n!}{k!(n-k)!}$")
204
+ rules.append(r"אירועים בלתי תלויים: $P(A \\cap B) = P(A) \\cdot P(B)$")
205
+
206
+ # V286.0: Advanced Calculus (חדו"א מתקדם — 5 יח"ל)
207
+ if any(x in t for x in ["אינטגרל", "שטח מתחת", "נפח סיבוב", "אינטגרציה"]):
208
+ if any(x in t for x in ["חלקי", "חלקים", "by parts"]):
209
+ rules.append(r"אינטגרציה בחלקים: $\\int u\\,dv = uv - \\int v\\,du$")
210
+ if any(x in t for x in ["הצבה", "substitution"]):
211
+ rules.append(r"אינטגרציה בהצבה: $\\int f(g(x))g'(x)dx = \\int f(u)du$ כאשר $u=g(x)$")
212
+ rules.append(r"שטח בין שני גרפים: $S = \\int_a^b |f(x) - g(x)|\\,dx$")
213
+ if any(x in t for x in ["סיבוב", "גוף סיבוב"]):
214
+ rules.append(r"נפח גוף סיבוב סביב ציר $x$: $V = \\pi\\int_a^b [f(x)]^2\\,dx$")
215
+ if any(x in t for x in ["לא אמיתי", "מוכלל", "improper"]):
216
+ rules.append(r"אינטגרל מוכלל: $\\int_a^{\\infty} f(x)dx = \\lim_{b\\to\\infty} \\int_a^b f(x)dx$")
217
+
218
+ # V286.0: Limits (גבולות — 5 יח"ל)
219
+ if any(x in t for x in ["גבול", "lim", "שואף", "אינסוף", "לופיטל"]):
220
+ rules.append(r"גבולות נחשבים: $\\lim_{x\\to 0}\\frac{\\sin x}{x} = 1$")
221
+ rules.append(r"$\\lim_{x\\to\\infty}\\left(1+\\frac{1}{x}\\right)^x = e$")
222
+ rules.append(r"כלל לופיטל: אם $\\frac{0}{0}$ או $\\frac{\\infty}{\\infty}$, אז $\\lim\\frac{f(x)}{g(x)} = \\lim\\frac{f'(x)}{g'(x)}$")
223
+ rules.append(r"אסטרטגיה ל-$\\frac{0}{0}$: פרק לגורמים, רציונליזציה, או לופיטל.")
224
+ rules.append(r"אסטרטגיה ל-$\\frac{\\infty}{\\infty}$ עם פולינומים: חלק במעלה הגבוהה ביותר.")
225
+
226
+ return rules
227
+
228
+ def get_data_extraction_prompt(problem_text: str) -> str:
229
+ """V231.13: Extract ALL equations, not just functions."""
230
+ return fr"""
231
+ EXTRACT key mathematical data from this problem. Return ONLY valid JSON.
232
+
233
+ Problem:
234
+ {problem_text}
235
+
236
+ Extract:
237
+ 1. **function_equations**: ALL equations with '=' sign
238
+ - Functions: f(x) = ..., g(x) = ...
239
+ - Circles: x² + y² = r², (x-a)² + (y-b)² = r²
240
+ - Lines: y = mx + b, ax + by = c
241
+ - ANY equation with '=' sign!
242
+
243
+ 2. **points**: Named points like A, B, M(3,5), P(x,y)
244
+
245
+ 3. **specific_values**: Numbers like r=5, a=3, m=2
246
+
247
+ 4. **constraints**: Conditions like x>0, x≠2, domain restrictions
248
+
249
+ 5. **sub_questions**: Parts א, ב, ג or a, b, c
250
+
251
+ 6. **geometric_anchors** (CRITICAL FOR VALIDATION):
252
+ - center: [x,y] coordinates if a circle center is given
253
+ - radius: numeric value if radius is given
254
+ - point_a, point_b: Strings like "(x,y)" if specific points are given for distance/lines
255
+
256
+ JSON format (STRUCTURE ONLY - DO NOT USE THESE EXACT VALUES):
257
+ {{
258
+ "function_equations": ["<equation_1>", "<equation_2>"],
259
+ "points": ["<point_name>", "<point_name>(<x>,<y>)"],
260
+ "specific_values": ["<variable>=<value>"],
261
+ "constraints": ["<constraint_1>"],
262
+ "sub_questions": ["<sub_question_1>"],
263
+ "center": [null, null],
264
+ "radius": null,
265
+ "point_a": "(null, null)",
266
+ "point_b": "(null, null)"
267
+ }}
268
+
269
+ CRITICAL INSTRUCTIONS:
270
+ 1. Include ALL equations with '=' sign in function_equations!
271
+ 2. **DATA INTEGRITY (V4.3.0):** Use the data below verbatim.
272
+ 3. **VARIABLE ENFORCEMENT (V4.0.1):** ALL equations must use standard single-letter mathematical variables (x, y, z, a, b, c, m, n, k).
273
+ - DO NOT extract descriptive English names like "number_of_notebooks".
274
+ - 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').
275
+ - Equations with descriptive multi-letter variables will crash the calculator.
276
+ 4. DO NOT HALLUCINATE OR GUESS! 🚫 If an equation or point is NOT explicitly written in the problem, DO NOT invent it.
277
+ 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).
278
+ 6. Example: Do not assume `f(x) = ax - x^2` just because it looks like a standard problem. Only extract what is literally there.
279
+ r"""
280
+
281
+ def get_specialist_prompt(category, problem_text, solver_hint, grade, student_name, student_gender="M", data_anchor=None):
282
+ """בניית הפרומפט המלכותי — המורה למתמטיקה V231.6 (Data Anchor)"""
283
+ features = _get_grade_features(grade, category)
284
+ relevant_rules = _detect_relevant_rules(problem_text, category)
285
+ rules_str = "\n".join([f" - {r}" for r in relevant_rules]) if relevant_rules else " (לא זוהו כללים ספציפיים — בחר רק כללים רלוונטיים לקטגוריה {category})"
286
+
287
+ # Anchor Block — DATA INTEGRITY RULE
288
+ anchor_block = ""
289
+ if data_anchor:
290
+ anchor_block = f"""
291
+ ══════════════════════════════════════════════════════
292
+ 📜 DATA INTEGRITY RULE (ABSOLUTE TRUTH):
293
+ ══════════════════════════════════════════════════════
294
+ The data below is the ABSOLUTE TRUTH extracted from the student's image.
295
+ If it contains function_equations or equations — those ARE the problem's data.
296
+ YOU MUST use them EXACTLY AS GIVEN. Never claim data is "missing" if it is in the data.
297
+ Never assume, invent, or substitute a different function under ANY circumstances.
298
+ Violating this rule is a critical pedagogical failure.
299
+ ══════════════════════════════════════════════════════
300
+ נתוני שאלת המקור (השתמש רק בהם):
301
+ {json.dumps(data_anchor, indent=2, ensure_ascii=False)}
302
+
303
+ CONSTRAINT: If the data says A(0,5), use A(0,5). If it contains f(x), solve that f(x).
304
+ """
305
+
306
+
307
+ # V231.5: Gender-aware phrases
308
+ if student_gender == "F":
309
+ g = {
310
+ "royal": "נסיכה",
311
+ "come": "בואי",
312
+ "ready": "מוכנה",
313
+ "great": "מעולה",
314
+ "solved": "פתרת",
315
+ "proud": "גאה בך",
316
+ "try_again": "בואי ננסה שוב יחד",
317
+ "example_open": f"כל הכבוד {student_name} נסיכה! 👑",
318
+ "example_close": f"כל הכבוד {student_name}! 👑 {student_gender == 'F' and 'פתרת' or 'פתרת'} מעולה. אני גאה בך!"
319
+ }
320
+ else:
321
+ g = {
322
+ "royal": "נסיך",
323
+ "come": "בוא",
324
+ "ready": "מוכן",
325
+ "great": "מעולה",
326
+ "solved": "פתרת",
327
+ "proud": "גאה בך",
328
+ "try_again": "בוא ננסה שוב יחד",
329
+ "example_open": f"כל הכבוד {student_name} נסיך! 👑",
330
+ "example_close": f"כל הכבוד {student_name}! 👑 פתרת מעולה. אני גאה בך!"
331
+ }
332
+ # V282.0: Proof-specific instructions (outside f-string to avoid backslash issues in Python 3.11)
333
+ proof_keywords = ["הוכח", "הוכיח", "הוכיחו", "הראה כי", "הראי כי"]
334
+ is_proof = any(x in problem_text for x in proof_keywords)
335
+ proof_block = ""
336
+ if is_proof:
337
+ proof_block = """
338
+ ══════════════════���════════════════════════════════
339
+ 📐 הוראות מיוחדות להוכחות (V282.0):
340
+ ═══════════════════════════════════════════════════
341
+
342
+ 🔴 זוהי שאלת הוכחה! החוקים הבאים הם חובה:
343
+ 1. **אסור לקפוץ לתשובה.** ההוכחה היא הדרך, לא התוצאה. התלמיד צריך לראות כל צעד.
344
+ 2. **מבנה חובה לכל צעד:** "נתון ש-[X]. לפי [שם המשפט/תכונה], מתקיים [Y]."
345
+ 3. **נימוק לכל מעבר:** לכל שוויון/אי-שוויון, ציין למה הוא נכון.
346
+ 4. **שרשרת לוגית:** כל צעד חייב להסתמך על צעד קודם או על נתון.
347
+ 5. **ציין שיטת הוכחה:** חפוש משולשים חופפים? תכונות של שווה שוקיים? זוויות מתחלפות?
348
+ 6. **סיום:** בסוף חובה לכתוב "ולכן הוכחנו ש-[מה שנדרש]. מ.ש.ל."
349
+ 7. **דוגמה למבנה טוב:**
350
+ - "נתון שהמשולש ABC שווה שוקיים (AB = AC). לפי תכונה: זוויות בסיס שוות."
351
+ - "חישבנו שזווית CDF שווה לזווית DCF. לפי משפט: במשולש ששתי זוויות בו שוות, הצלעות מולן שוות, DC = CF."
352
+ 8. **אסור:** לכתוב "DC = CF" בלי להסביר למה. חובה לבנות את כל שרשרת ההוכחה.
353
+ """
354
+ # V285.1: Investigation Table (Table UI)
355
+ investigation_keywords = ["חקור", "חקירת", "קיצון", "אסימפטוט", "עליה", "ירידה"]
356
+ is_investigation = "INVESTIGATION" in category or any(x in problem_text for x in investigation_keywords)
357
+ investigation_block = ""
358
+ if is_investigation:
359
+ investigation_block = """
360
+ ═══════════════════════════════════════════════════
361
+ 📈 הוראות מיוחדות לחקירת פונקציה (Table UI):
362
+ ═══════════════════════════════════════════════════
363
+
364
+ בנוסף לשדות הרגילים ב-JSON, באחריותך להוסיף בשורש ה-JSON את השדה "investigation" המילוני הזה:
365
+ "investigation": {
366
+ "function": "הפונקציה ב-LaTeX",
367
+ "derivative": "הנגזרת ב-LaTeX",
368
+ "second_derivative": "נגזרת שנייה ב-LaTeX (השאר ריק אם אין צורך)",
369
+ "domain": "תחום הגדרה עטוף ב-LaTeX",
370
+ "critical_points": [
371
+ {"x": "ערך x", "y": "ערך y", "f_prime": "0", "behavior": "מקסימום/מינימום"}
372
+ ],
373
+ "increasing": ["(a, b)", "(1, \\infty)"],
374
+ "decreasing": ["(-\\infty, a)"],
375
+ "concave_up": ["(a, b)"],
376
+ "concave_down": ["(b, c)"]
377
+ }
378
+ """
379
+
380
+ return f"""
381
+ 🎓 תפקיד: אתה "המורה למתמטיקה" — מורה פרטית חמה ומעודדת בגישת 'הנסיך והנסיכה'.
382
+ 🌟 הנחיה עליונה: הפוך את הלמידה לחוויה מעצימה, אישית ונעימה עבור {student_name}.
383
+ 👑 מגדר: התלמיד/ה הוא/היא {g['royal']}. השתמש/י בלשון התאימה למגדר זה.
384
+
385
+ 🏰 חוק הזרימה והפרסונליזציה (Continuous Persona Rule):
386
+
387
+ • הקשר רציף: אתה פותר עכשיו סעיף אחד מתוך שאלה גדולה. אל תתחיל כל סעיף בברכת שלום דרמטית או היכרות מחודשת! זרום ישירות להסבר עם מילת קישור (למשל: "כעת נמשיך ל...", "כדי למצוא את...", "בסעיף זה נתמקד ב...").
388
+
389
+ • התאמת גיל ופרופיל: התלמיד מולך הוא {student_name} הלומד בכיתה {grade}. דברו אליו בגובה העיניים, בשפה שמתאימה לגילו. בלי התיילדות יתר, אלא בטון מקצועי, מעצים ובוגר.
390
+
391
+ • חיזוקים טבעיים: שלבו חיזוקים חיוביים בצורה עדינה וטבעית בסוף הסעיף, לא בכל משפט (למשל: "יופי של עבודה עד כה", "הבנת את העיקרון המרכזי כאן").
392
+
393
+ • ספציפיות קריטית (V261.16): אסור לכתוב "לפי המשפט" או "כפי שלמדנו" בלי לפרט איזה משפט ומה הוא אומר.
394
+ - לא טוב: "לפי המשפט, הזוויות שוות."
395
+ - מצוין: "בגלל שזוויות מתחלפות בין ישרים מקבילים הן שוות, אז זווית A שווה לזווית B."
396
+
397
+ • אימות OCR והשגחה:
398
+ • אסור בהחלט לשנות את הפונקציה או הנתונים שהתקבלו מה-OCR! 🚫
399
+ • אם ה-OCR זיהה $\frac{{x^2}}{{x^2-4}}$ — חובה לפתור בדיוק את הפונקציה הזו!
400
+ • אם נקודות $A, D$ נמצאות על ציר $y$ — זה אומר $x = 0$! לא $y = 0$!
401
+
402
+ 🔒 חוקי אימות חישובי (V282.3 — CRITICAL):
403
+ • **אסור להניח מיקום נקודה בלי חישוב אלגברי.** אם נתונות משוואות ישרים — חשב נקודת חיתוך ע"י פתרון מערכת המשוואות. לעולם אל תניח שנקודה או מרכז מעגל נמצאים על ציר ספציפי (למשל x=0) בלי הוכחה מתמטית אלגברית ברורה בצעדים.
404
+ • **הצבה חוזרת חובה:** אחרי שמצאת שיעורי נקודה — הצב אותם בחזרה בכל המשוואות הנתונות כדי לוודא שהם מקיימים את כולן.
405
+ • **בדיקה עצמית לפני תשובה סופית:** לפני שאתה כותב את התשובה הסופית — עבור על כל תוצאת ביניים ובדוק שהיא עקבית עם כל הנתונים.
406
+
407
+ {anchor_block}
408
+
409
+ 📚 רקע תיאורטי לשאלה (§4.1 — רלוונטי בלבד):
410
+ {rules_str}
411
+
412
+ 🎯 קטגוריה: {category}
413
+ 📊 רמת הכיתה: {grade} ({features['depth']})
414
+
415
+ {proof_block}
416
+ {investigation_block}
417
+ """
418
+
419
+
420
+ # prompts.py - V275.1 (Safe OCR - Technique over Examples)
421
+ def get_transcription_prompt():
422
+ """
423
+ V275.1: Safe OCR Prompt - Teaches TECHNIQUE, not specific examples.
424
+
425
+ Why V275.1?
426
+ - V275.0 had specific examples like "\\frac{1}{x}(a + \\frac{(\\ln x)^n}{n})"
427
+ - This caused the LLM to "fit" different functions to the example
428
+ - Now we teach HOW to recognize patterns, not WHAT pattern to expect
429
+ """
430
+ return """
431
+ TRANSCRIBE the FULL text from the image. Include ALL Hebrew text and ALL mathematical expressions.
432
+
433
+ 🎯 YOUR ONLY JOB: Copy EXACTLY what you see. Do NOT interpret, simplify, or guess.
434
+
435
+ 📐 TRANSCRIPTION TECHNIQUE (How to be accurate):
436
+
437
+ 1. **SCAN STRUCTURE FIRST**
438
+ Before writing anything, identify the STRUCTURE:
439
+ - Is there a fraction? Look for horizontal lines
440
+ - Is there nesting? (something inside something else)
441
+ - Are there exponents? Look for small raised text
442
+ - Are there subscripts? Look for small lowered text
443
+
444
+ 2. **WORK OUTSIDE-IN**
445
+ For complex expressions:
446
+ - First identify the OUTERMOST structure
447
+ - Then identify what's INSIDE each part
448
+ - Write the LaTeX from outside to inside
449
+
450
+ 3. **FRACTION DETECTION**
451
+ When you see a horizontal line:
452
+ - Everything ABOVE the line is the numerator
453
+ - Everything BELOW the line is the denominator
454
+ - If there's ANOTHER horizontal line inside → nested fraction!
455
+ - Use \\frac{numerator}{denominator}
456
+
457
+ 4. **PARENTHESES CONTENT**
458
+ When you see parentheses/brackets:
459
+ - Transcribe EVERYTHING inside, no matter how complex
460
+ - Don't summarize or skip parts
461
+ - Use \\left( and \\right) for large parentheses
462
+
463
+ 5. **EXPONENTS AND SUBSCRIPTS**
464
+ - Small text ABOVE the line → exponent: x^{...}
465
+ - Small text BELOW the line → subscript: x_{...}
466
+ - If the exponent is complex (like n-1), use braces: x^{n-1}
467
+
468
+ 6. **HEBREW TEXT**
469
+ - Transcribe ALL Hebrew instructions exactly
470
+ - Fully transcribe any top-level header or main question text before the sub-questions
471
+ - Include all list items formatting and sub-question letters
472
+ - Include ALL conditions and constraints
473
+
474
+ 🚫 FORBIDDEN:
475
+ - Do NOT simplify expressions
476
+ - Do NOT skip "obvious" parts
477
+ - Do NOT assume you know what the function should be
478
+ - Do NOT change the structure you see
479
+
480
+ ✅ OUTPUT: The EXACT text from the image, with proper LaTeX for math.
481
+ """
482
+
483
+ # ==================== V260.0 PEDAGOGICAL PROMPTS ====================
484
+
485
+ def get_strategy_card_prompt(problem_text: str, data_anchor: dict) -> str:
486
+ """V260.1: Generate high-level strategy as HINTS to encourage self-solving."""
487
+ return f"""
488
+ ROLE: Elite Math Tutor (Pedagogical Architect).
489
+ TASK: Analyze this problem and provide a high-level STRATEGY guide filled with HINTS.
490
+
491
+ PROBLEM:
492
+ {problem_text}
493
+
494
+ DATA (Context):
495
+ {json.dumps(data_anchor, ensure_ascii=False)}
496
+
497
+ INSTRUCTIONS:
498
+ 1. Explain the LOGIC of how to approach this problem, but frame it as hints.
499
+ 2. === STRATEGY CARD PEDAGOGY RULE ===
500
+ CRITICAL: DO NOT solve the problem here. DO NOT use actual equations or numbers.
501
+ Speak conceptually. Give the student the "blueprint" so they can try it themselves.
502
+ End the `content` section with an encouraging call to action to try it alone first! (e.g., "קחו דף ועט, נסו לפתור לבד לפי הרמזים, ואם נתקעתם - הפתרון המלא מחכה לכם למטה!").
503
+ 3. Provide 3-4 bullet points acting as stepping stones/hints.
504
+ 4. CRITICAL: Output MUST be in HEBREW (עברית).
505
+ 5. Tone: Encouraging, challenging, empowering.
506
+
507
+ OUTPUT JSON:
508
+ {{
509
+ "title": "איך ניגשים לשאלה הזו? 💡",
510
+ "content": "הסבר מילולי קצר המעודד עבודה עצמאית...",
511
+ "steps": [
512
+ "רמז 1: מכיוון שנתונה נקודת קיצון, נסו לחשוב מה זה אומר על הנגזרת...",
513
+ "רמז 2: חיתוך עם הצירים דורש מכם..."
514
+ ]
515
+ }}
516
+ """
517
+
518
+ def get_visual_context_prompt(problem_text: str, category: str) -> str:
519
+ """V260.0: Generate visual description for the sketch card."""
520
+ return f"""
521
+ ROLE: Visual describer for blind students / schematic generator.
522
+ TASK: Describe the VISUAL SETUP of this math problem.
523
+
524
+ PROBLEM:
525
+ {problem_text}
526
+
527
+ CATEGORY: {category}
528
+
529
+ INSTRUCTIONS:
530
+ 1. If GEOMETRY: Describe the shapes, how they connect, what is tangent to what.
531
+ - **CRITICAL**: Populate "geometric_entities" with PRECISE COORDINATES for plotting.
532
+ - If no coordinates are given, ESTIMATE logical coordinates (e.g., A(0,0), B(3,0) for a base).
533
+ 2. If FUNCTION: Describe the graph shape (parabola opening up/down), intersection points, asymptotes.
534
+ 3. Goal: Help a student "see" the problem before solving.
535
+ 4. Keep it simple and descriptive.
536
+ 5. CRITICAL: Output MUST be in HEBREW (עברית). Explain the visuals in Hebrew.
537
+
538
+ OUTPUT JSON (STRUCTURE ONLY - USE ACTUAL PROBLEM DATA):
539
+ {{
540
+ "title": "המחשה חזותית ✏️",
541
+ "description": "הסבר מילולי קצר...",
542
+ "geometric_entities": {{
543
+ "points": [{{"label": "<point_name>", "x": 0.0, "y": 0.0}}],
544
+ "segments": [{{"start": "<point_name>", "end": "<point_name>", "color": "blue"}}],
545
+ "circles": [{{"center_x": 0.0, "center_y": 0.0, "radius": 0.0}}]
546
+ }},
547
+ "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."
548
+ }}
549
+
550
+ 🚨 GRAPH PERSISTENCE RULES (V300.3 — CRITICAL):
551
+ - 'latex_input' is MANDATORY. You MUST provide it. NEVER leave it as null or empty string "".
552
+ - NEVER say "I cannot draw" or "no graph possible". Always provide your best equation.
553
+ - For GEOMETRY: use the main circle/line equation (e.g. "(x-3)^2 + (y-5)^2 = 39").
554
+ - For FUNCTION: use the function equation (e.g. "\\frac{{\\ln(x)}}{{x^2-4}}").
555
+ - For LOCUS: write the final locus equation.
556
+ - If you are TRULY unsure: use "x" as a placeholder — the server will handle it.
557
+ - The latex_input must use valid LaTeX WITHOUT $$ wrappers (e.g. "x^2 + y^2 = 25" not "$$x^2+y^2=25$$").
558
+
559
+ 🚨 HELPER SKETCH RULE (V300.3 — NEW):
560
+ If no image is provided (blind setup):
561
+ - Read the verbal description of the geometry/trigonometry problem carefully.
562
+ - Extract coordinates, lines, or functions from the text to generate an illustration sketch.
563
+ - Your goal is to CREATE the visual intuition that the student is missing.
564
+ - Even without an image, you MUST populate "geometric_entities" and "latex_input".
565
+ """
566
+
567
+
568
+ # ==================== V8.6.8 MASTER PROMPT (THE ANCHOR STABILITY FIX) ====================
569
+
570
+ def get_master_prompt_v860():
571
+ """
572
+ V8.6.8: The Anchor Stability Fix.
573
+ Prevents NameErrors and UI rendering crashes.
574
+ """
575
+ return r"""
576
+ 🔴 MASTER PROMPT BLOCK — VERSION V8.6.8 (THE ANCHOR STABILITY)
577
+
578
+ CRITICAL: You MUST output ONLY valid JSON. Absolutely NO conversational text before or after the JSON block.
579
+ Do NOT wrap the JSON in markdown code blocks like ```json.
580
+
581
+ ROLE:
582
+ אתה מורה פרטי למתמטיקה (הטוב ביותר בארץ!). המטרה שלך היא להסביר לתלמיד לא רק *איך* פותרים, אלא *למה* עושים כל צעד. רמת ההסבר צריכה להיות כזו שגם תתלמיד שרואה את החומר פעם ראשונה יבין 100% מהדרך. הגישה שלך היא חמה, מעודדת ומעצימה (סגנון 'הנסיך/הנסיכה').
583
+
584
+ ═══════════════════════════════════════════
585
+ ABSOLUTE RULES (violations cause immediate rejection):
586
+ ═══════════════════════════════════════════
587
+ 1. **DATA ANCHOR SUPREMACY:** The equations in the JSON Data Anchor are the ABSOLUTE TRUTH. Ignore any conflicting OCR text.
588
+ 2. **ZERO MAGIC MATH (BABY STEPS):**
589
+ - NEVER skip algebraic steps. Show moving sides, dividing, and expanding brackets.
590
+ - ALWAYS explain the mathematical rule/theorem *BEFORE* applying it.
591
+ * Bad: "נגזור ונשווה לאפס: f'(x) = 2x"
592
+ * Good: "כדי למצוא נקודת קיצון נגזור את הפונקציה. מכיוון שזו מנה, נשתמש בכלל המנה האומר ש... נגזור את המונה בנפרד ואת המכנה בנפרד:"
593
+ 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).
594
+ 4. **UI CONTENT STRATEGY (CRITICAL - MATH SEPARATION & MULTI-STEP LOGIC):**
595
+ - `content_mixed`: ONLY for your warm Hebrew explanation and short inline variables (e.g. $x=5$). Do NOT put long equations, derivatives, or multi-line steps here!
596
+ - `block_math`: This is where the ACTUAL CALCULATION goes. It must contain the main equation or algebraic step in PURE LaTeX.
597
+ - IF there is any mathematical derivation or calculation in a step, it MUST go into `block_math` and NOT into `content_mixed`. NEVER put an equation on a new line inside `content_mixed`.
598
+ - **MULTI-STEP DERIVATIONS:** If a calculation requires multiple algebraic lines (like a complex derivative or expanding brackets), DO NOT SKIP THEM! Break them down into **MULTIPLE JSON steps**! Explain ONE part in `content_mixed`, show the intermediate math in `block_math`, then create a NEW step for the next part.
599
+ - NEVER put Hebrew inside `block_math` (no \\text{עברית}), it will crash the app!
600
+ 5. **ANTI-TABLE RULE & EXTREMA ANALYSIS:**
601
+ - NEVER use Markdown tables (e.g., using | and -).
602
+ - When finding Extrema (Max/Min), DO NOT suggest using a table. Instead, use explicit text steps to test the intervals.
603
+ - Example: "נבדוק את תחומי העלייה והירידה: עבור $x=\pi/4$ הנגזרת חיובית, ועבור $x=3\pi/4$ היא שלילית. לכן זו נקודת מקסימום".
604
+ - You MUST complete the calculation, find the Y values ($y=f(x)$), and explicitly state the classification (Max or Min).
605
+ 6. **STRICT JSON ONLY:** No preamble, no post-amble, no markdown.
606
+ 7. **PARADOX PROTOCOL (V8.6.6):**
607
+ - If your mathematical calculation contradicts the question's premise (e.g., calculation shows $e^x$ but objective is to find info for $e^{-x}$), DO NOT APOLOGIZE or hallucinate.
608
+ - State factually in `content_mixed`: "אני מזהה סתירה בנתונים, בואו נבדוק שוב את הפונקציה המקורית. על פי החישוב שלי [הסבר קצר...]."
609
+ - Focus on the TRUTH of your calculation. Never force a derivation to match a (likely misread) OCR error.
610
+ - **Mathematical Logic (V8.6.6):** If OCR is ambiguous (e.g. $e^x$ vs $e^{-x}$), use the overall context of the question (e.g., domain, asymptotes, or known behavior) to determine the logically correct formula.
611
+ 8. **ANTI-NEWLINE RULE (V8.6.8):**
612
+ - In the `final_answer` field, NEVER use `\\` or `\newline` for line breaks.
613
+ - If there are multiple answers, separate them with commas or Hebrew text (e.g., "x=1, x=2" or "x=1 או x=2").
614
+ 9. **VARIABLE CONSISTENCY (V8.6.8):**
615
+ - 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.
616
+ 10. **STRATEGY CARD NO-SPOILER RULE (CRITICAL):**
617
+ - The `strategy_card` MUST NOT contain any numbers, final equations, derivatives, or solutions.
618
+ - It must ONLY contain high-level conceptual hints ("רמז 1: כדי למצוא קיצון, חשבו על...").
619
+ 11. **NO HEBREW IN LATEX OR ANSWERS (CRITICAL FOR UI RENDERER):**
620
+ - NEVER include Hebrew words inside LaTeX math blocks (like `formulas` or `block_math`). The UI renderer cannot handle RTL properly inside math blocks.
621
+ - Use English/Greek letters for indices (e.g. $S_{total}$ and NOT $S_{סך_הכל}$).
622
+ - The `final_answer` field MUST contain ONLY the mathematical answer (e.g. `x=5`). Do NOT write "תשובה: x=5" inside the field!
623
+ 12. **PEDAGOGICAL HIGHLIGHTING (CRITICAL):**
624
+ - When performing a substitution, showing a change in sign, taking a derivative, or highlighting a key transition in a calculation inside `block_math` or `content_mixed`, use `\\color{red}{...}` or `\\color{blue}{...}` to visually highlight the element that changed. (e.g., `\\color{red}{x^2}`, `\\color{blue}{+4}`).
625
+ - Only wrap valid Math inside the color tag. Do not color Hebrew text.
626
+
627
+
628
+ ═══════════════════════════════════════════
629
+ REQUIRED JSON STRUCTURE (EXACT KEYS):
630
+ ═══════════════════════════════════════════
631
+ {
632
+ "strategy_card": {
633
+ "title": "איך ניגשים לשאלה הזו? 🧭",
634
+ "intro": "היי! נראה שיש לנו פה שאלת [נושא] מצוינת...",
635
+ "bullets": ["רמז 1: [רמז מוכוון פעולה ללא ספוילרים או תשובות]", "רמז 2: [רמז נוסף]"],
636
+ "call_to_action": "קחו דף ועט, נסו לפתור לבד לפי הרמזים, ואם נתקעתם – פתחו את הסעיפים למטה!"
637
+ },
638
+ "approach": "הקדמה מלאה: פתיח חם, אישי ומלמד שמסביר בשפה פתוחה מה נלך לעשות בתרגיל ולמה (למשל: 'כדי למצוא אסימפטוטות אנחנו נבדוק מה קורה בפלוס ומינוס אינסוף').",
639
+ "steps": [
640
+ {
641
+ "step_id": 1,
642
+ "content_mixed": "(הסבר פדגוגי מפורט ואמהי) כדי למצוא את הנגזרת נשתמש בכלל המנה, ונגזור את המונה בנפרד ואת המכנה בנפרד:",
643
+ "block_math": "y' = \\frac{\\color{blue}{2x} \\cdot (x-1) - x^2 \\cdot \\color{blue}{1}}{(x-1)^2}"
644
+ },
645
+ {
646
+ "step_id": 2,
647
+ "content_mixed": "(המשך הסבר חם ומפורט) נציב כעת $x=\\color{red}{5}$ במשוואה שצמצמנו:",
648
+ "block_math": "y' = \\frac{\\color{red}{5}^2 - 2\\cdot \\color{red}{5}}{(\\color{red}{5}-1)^2} = \\frac{15}{16}"
649
+ }
650
+ ],
651
+ "final_answer": "התשובה הסופית נטו (LaTeX)",
652
+ "teacher_summary": {
653
+ "audio_pitch": "פיצ' שיחתי (כמו פודקאסט קצר) של 30-40 שניות שבו המורה מסכמת את התרגיל וחולקת תובנות. כתבי בטון אמהי, חם ומעודד.",
654
+ "key_concepts": ["סיכום מלמד 1: הפקת לקחים ותובנה אסטרטגית מהתרגיל (למשל 'שימו לב שתמיד לפני גזירת מנה נרצה לסדר את הפונקציה')", "סיכום מלמד 2: כלל אצבע שאפשר לקחת משאלה זו הלאה"],
655
+ "formulas": ["נוסחאות מרכזיות בהן השתמשנו, בפורמט LaTeX נקי (ללא סוגרי דולר)"]
656
+ }
657
+ }
658
+
659
+ CRITICAL: Output ONLY the JSON block. No text before, no text after. No markdown formatting.
660
+ """
661
+
662
+ def get_master_prompt_v430():
663
+ """V4.3.0: Legacy placeholder, redirecting to V8.6.0 for 'The Golden Merge'"""
664
+ return get_master_prompt_v860()
665
+
666
+
667
+ # ==================== V285.0: CHECK ME PROMPT (HOMEWORK VERIFICATION) ====================
668
+
669
+ def get_check_me_prompt(grade: str, student_name: str, student_gender: str = "M"):
670
+ """
671
+ V285.0: Dedicated prompt for the "Check Me" feature.
672
+ The LLM acts as a homework checker, NOT a solver.
673
+ It receives the student's image and analyzes their work step-by-step.
674
+ """
675
+ # Gender-aware phrases
676
+ if student_gender == "F":
677
+ g_addr = "את"
678
+ g_did = "עשית"
679
+ g_chose = "בחרת"
680
+ g_forgot = "שכחת"
681
+ g_started = "התחלת"
682
+ g_great = "מעולה"
683
+ g_dear = f"{student_name} יקרה"
684
+ else:
685
+ g_addr = "אתה"
686
+ g_did = "עשית"
687
+ g_chose = "בחרת"
688
+ g_forgot = "שכחת"
689
+ g_started = "התחלת"
690
+ g_great = "מעולה"
691
+ g_dear = f"{student_name} יקר"
692
+
693
+ return f"""
694
+ 🎓 תפקיד: אתה בודקת שיעורי בית — מורה פרטית חמה שבודקת את העבודה של תלמיד.
695
+ 🚫 אתה לא פותר את התרגיל מחדש! אתה מנתח את מה שהתלמיד כתב.
696
+
697
+ 👤 התלמיד: {student_name}, כיתה {grade}.
698
+ 👑 מגדר: {"נקבה" if student_gender == "F" else "זכר"}. השתמש/י בלשון מתאימה.
699
+
700
+ ═══════════════════════════════════════════════════
701
+ 📐 שלוש שלבי הבדיקה (חובה לבצע לפי הסדר):
702
+ ═══════════════════════════════════════════════════
703
+
704
+ שלב א' — בדיקת מתודולוגיה (אסטרטגיה):
705
+ • זהה את התרגיל מתוך התמונה.
706
+ • בדוק: האם התלמיד בכלל בחר בשיטת פתרון נכונה?
707
+ • למשל: האם השתמש בנוסחת השורשים כשצריך פירוק? האם גזר כשצריך אינטגרל?
708
+ • אם השיטה שגויה מיסודה — עצור כאן. הסבר את הטעות התפיסתית בלבד.
709
+
710
+ שלב ב' — אימות אלגברי צעד-אחר-צעד:
711
+ • סרוק את שורות הפתרון שהתלמיד כתב.
712
+ • בדוק כל מעבר: העברת אגפים, כינוס איברים, סימנים, חזקות.
713
+ • ברגע שמזהה שבירה של חוק אלגברי — בודד את השורה המדויקת.
714
+ • ציין מה היה צריך להיות ולמה.
715
+
716
+ שלב ג' — רכיבים ויזואליים:
717
+ • אם התלמיד צייר גרף, שרטוט גיאומטרי, או טבלת סימנים שגויים — ציין מה שגוי.
718
+ • אם אין רכיב ויזואלי — דלג על שלב זה.
719
+
720
+ ═══════════════════════════════════════════════════
721
+ 🎯 כללי ברזל:
722
+ ═══════════════════════════════════════════════════
723
+ 1. אל תפתור את התרגיל מחדש! רק בדוק את מה שהתלמיד כתב.
724
+ 2. אם הכל נכון — תן חיזוק חיובי אמיתי ומפורט.
725
+ 3. אם יש טעות — הצבע על השורה המדויקת, הסבר מה שגוי, ומה היה צריך להיות.
726
+ 4. טון: חם, מעודד, מקצועי. כמו מורה פרטית שבודקת מבחן עם העט האדום, אבל בלב חם.
727
+ 5. כל התשובה בעברית.
728
+ 6. אם אתה לא מצליח לזהות את כתב היד — ציין זאת בנימוס ובקש צילום ברור יותר.
729
+
730
+ ═══════════════════════════════════════════════════
731
+ 📋 פורמט JSON נדרש (STRICT — ללא טקסט לפני או אחרי):
732
+ ═══════════════════════════════════════════════════
733
+ {{
734
+ "verdict": "correct" | "has_errors" | "methodology_error" | "unreadable",
735
+ "problem_identified": "מה התרגיל שזוהה מהתמונה (LaTeX)",
736
+ "methodology_ok": true | false,
737
+ "methodology_note": "הערה על השיטה שנבחרה (ריק אם הכל תקין)",
738
+ "feedback_steps": [
739
+ {{
740
+ "step_id": 1,
741
+ "student_wrote": "מה שהתלמיד כתב בשורה זו (LaTeX)",
742
+ "is_correct": true | false,
743
+ "error_description": "אם שגוי: מה הטעות ולמה",
744
+ "should_be": "מה היה צריך להיות (LaTeX, ריק אם נכון)"
745
+ }}
746
+ ],
747
+ "visual_note": "הערה על שרטוט/גרף אם רלוונטי, אחרת null",
748
+ "encouragement": "משפט חיזוק חיובי אישי ל{student_name}",
749
+ "correct_final_answer": "התשובה הנכונה של התרגיל (LaTeX)"
750
+ }}
751
+
752
+ CRITICAL: Output ONLY the JSON block. No text before, no text after. No markdown formatting.
753
+ """
754
+
755
+
756
+ # ==================== V285.1: TEACHER SUMMARY PROMPT (PEDAGOGICAL) ====================
757
+
758
+ def get_teacher_summary_prompt(student_name: str, student_gender: str = "M"):
759
+ """
760
+ V285.1: Prompt for generating a pedagogical teacher summary.
761
+ The LLM summarizes what was learned, key concepts, formulas, and generates TTS text.
762
+ """
763
+ if student_gender == "F":
764
+ g_addr = "את"
765
+ g_learned = "למדת"
766
+ g_solved = "פתרת"
767
+ g_dear = student_name
768
+ else:
769
+ g_addr = "אתה"
770
+ g_learned = "למדת"
771
+ g_solved = "פתרת"
772
+ g_dear = student_name
773
+
774
+ return f"""
775
+ אתה מורה למתמטיקה שמסכמת שיעור. קיבלת את הבעיה ואת הפתרון שנוצר.
776
+ המשימה שלך: ליצור סיכום פדגוגי שמתמקד בנושא שנלמד, לא בשאלה הספציפית.
777
+
778
+ 👤 התלמיד: {student_name}
779
+ 👑 מגדר: {"נקבה" if student_gender == "F" else "זכר"}
780
+
781
+ ═══════════════════════════════════════════════════
782
+ 📋 מה לכלול בסיכום:
783
+ ═══════════════════════════════════════════════════
784
+
785
+ 1. topic_summary: סיכום קצר של הנושא המתמטי שעסקנו בו (לא השאלה עצמה).
786
+ למשל: "גזירת פונקציות פולינומיאליות" או "חישוב שטח מתחת לגרף".
787
+
788
+ 2. key_concepts: רשימה של 2-4 תובנות מפתח ונקודות חשובות שכדאי לזכור לתרגילים הבאים.
789
+ למשל: "כשגוזרים חזקה, מורידים את המעריך ומחסרים 1" — כללי, לא ספציפי.
790
+
791
+ 3. formulas_to_remember: רשימה של 1-3 נוסחאות גנריות שהשתמשנו בהן.
792
+ לכתוב ב-LaTeX.
793
+ למשל: "\\\\frac{{d}}{{dx}} x^n = n \\\\cdot x^{{n-1}}"
794
+ אל תכלול ביטויים מספריים ספציפיים מהשאלה! רק נוסחאות כלליות.
795
+
796
+ 4. tts_speech: טקסט דיבור קצר וחם למורה (4-6 משפטים).
797
+ ⚠️ כללי ברזל ל-TTS:
798
+ - עברית בלבד, ללא LaTeX, ללא סימנים מתמטיים, ללא אימוג'ים
799
+ - אל תקרא לתלמיד "נסיך", "נס��כה", "גיבור", "אלוף" - רק בשם {student_name}
800
+ - תדבר בגוף שני {"נקבה" if student_gender == "F" else "זכר"}
801
+ - טון: חם ומקצועי, כמו מורה פרטית שמסכמת שיעור
802
+ - ציין את הנושא שלמדנו, תובנה מרכזית אחת, ומשפט עידוד
803
+
804
+ ═══════════════════════════════════════════════════
805
+ 📋 פורמט JSON נדרש:
806
+ ═══════════════════════════════════════════════════
807
+ {{
808
+ "topic_summary": "שם הנושא שנלמד (קצר)",
809
+ "key_concepts": ["תובנה 1", "תובנה 2", "תובנה 3"],
810
+ "formulas_to_remember": ["LaTeX נוסחה 1", "LaTeX נוסחה 2"],
811
+ "tts_speech": "טקסט דיבור עברי נקי ל-TTS"
812
+ }}
813
+
814
+ CRITICAL: Output ONLY the JSON block. No text before, no text after.
815
+ """
proof_graph.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # proof_graph.py - Immutable Mathematical Structure
2
+
3
+ from pydantic import BaseModel, Field
4
+ from typing import Optional, List, Dict, Any
5
+
6
+ class ProofStep(BaseModel):
7
+ step_id: int
8
+ math_content: str # Immutable SymPy/LaTeX (or math_artifact)
9
+ logic_description: str = ""
10
+ operator_used: str = ""
11
+ # V6 Additions:
12
+ rule_id: str = "general_algebra"
13
+ allowed_concepts: List[str] = Field(default_factory=list)
14
+ complexity_score: float = 1.0
15
+ pedagogical_tag: str = "כללי"
16
+
17
+ class ProofGraph:
18
+ def __init__(self, steps: list[ProofStep]):
19
+ self.steps = steps
20
+ self.is_verified = False
21
+
22
+ def verify_consistency(self):
23
+ # SymPy identity checks between steps
24
+ pass
25
+
26
+ def validate_pedagogical_legality(proof_graph: ProofGraph, curriculum_rules: dict) -> tuple[bool, str]:
27
+ """
28
+ V4.0: The Hard Enforcement Rule.
29
+ Checks if any step in the proof graph uses a forbidden math operator.
30
+ """
31
+ forbidden = curriculum_rules.get("forbidden", [])
32
+ reason_map = curriculum_rules.get("reason_map", {})
33
+
34
+ for step in proof_graph.steps:
35
+ if step.operator_used in forbidden:
36
+ detailed_reason = reason_map.get(step.operator_used, step.operator_used)
37
+ msg = f"Forbidden operator used: {detailed_reason}. Not allowed for this grade/level."
38
+ print(f"🚨 [PEDAGOGY-GUARD] REJECTED: {msg}")
39
+ return False, msg
40
+
41
+ return True, "Solution is pedagogically legal."
quota_system.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import datetime
4
+ from threading import Lock
5
+ from config import IS_PRODUCTION # Added for V3.1.3 Quota Override
6
+
7
+ import tempfile
8
+ import shutil
9
+
10
+ LIMITS_FILE = "student_limits.json"
11
+ # V260.9: Use /tmp or local for usage stats to avoid permission errors
12
+ USAGE_FILENAME = "usage_stats.json"
13
+
14
+ # Function to get writable usage path
15
+ def get_usage_file_path():
16
+ # Try local first
17
+ if os.access(".", os.W_OK):
18
+ return USAGE_FILENAME
19
+ # Fallback to temp dir
20
+ return os.path.join(tempfile.gettempdir(), USAGE_FILENAME)
21
+
22
+ USAGE_FILE = get_usage_file_path()
23
+
24
+ # V261: Persistent user tracking
25
+ ALL_USERS_FILE = "all_users.json"
26
+
27
+ class QuotaManager:
28
+ def __init__(self):
29
+ self.lock = Lock()
30
+ print(f"📊 [QUOTA] Using stats file: {USAGE_FILE}")
31
+ self._load_limits()
32
+ self._ensure_files()
33
+
34
+ def _ensure_files(self):
35
+ if not os.path.exists(LIMITS_FILE):
36
+ try:
37
+ with open(LIMITS_FILE, 'w', encoding='utf-8') as f:
38
+ json.dump({"default_limit": 30, "students": {}}, f)
39
+ except PermissionError:
40
+ print(f"⚠️ [QUOTA] Cannot create defaults limits file (Read-only fs)")
41
+
42
+ if not os.path.exists(USAGE_FILE):
43
+ try:
44
+ with open(USAGE_FILE, 'w', encoding='utf-8') as f:
45
+ json.dump({}, f)
46
+ except Exception as e:
47
+ print(f"⚠️ [QUOTA] Failed to init usage file: {e}")
48
+
49
+ # V261: Ensure persistent user list exists
50
+ if not os.path.exists(ALL_USERS_FILE):
51
+ try:
52
+ with open(ALL_USERS_FILE, 'w', encoding='utf-8') as f:
53
+ json.dump({}, f)
54
+ print(f"📊 [QUOTA] Created {ALL_USERS_FILE}")
55
+ except Exception as e:
56
+ print(f"⚠️ [QUOTA] Failed to init all_users file: {e}")
57
+
58
+ def _load_limits(self):
59
+ try:
60
+ with open(LIMITS_FILE, 'r', encoding='utf-8') as f:
61
+ self.limits_config = json.load(f)
62
+ except Exception as e:
63
+ print(f"⚠️ [QUOTA] Failed to load limits: {e}")
64
+ self.limits_config = {"default_limit": 30, "students": {}, "blocked_users": []}
65
+
66
+ # V3.1.3: DEV Quota Reset
67
+ if not IS_PRODUCTION:
68
+ self.limits_config["default_limit"] = 1000
69
+
70
+ def _load_usage(self):
71
+ try:
72
+ with open(USAGE_FILE, 'r', encoding='utf-8') as f:
73
+ return json.load(f)
74
+ except Exception:
75
+ return {}
76
+
77
+ def _save_usage(self, usage_data):
78
+ try:
79
+ with open(USAGE_FILE, 'w', encoding='utf-8') as f:
80
+ json.dump(usage_data, f, indent=2)
81
+ except Exception as e:
82
+ print(f"⚠️ [QUOTA] Failed to save usage: {e}")
83
+
84
+ def _load_all_users(self):
85
+ try:
86
+ with open(ALL_USERS_FILE, 'r', encoding='utf-8') as f:
87
+ return json.load(f)
88
+ except:
89
+ return {}
90
+
91
+ def _save_all_users(self, data):
92
+ try:
93
+ with open(ALL_USERS_FILE, 'w', encoding='utf-8') as f:
94
+ json.dump(data, f, indent=2)
95
+ except Exception as e:
96
+ print(f"⚠️ [QUOTA] Failed to save all_users: {e}")
97
+
98
+ def get_today_key(self):
99
+ return datetime.date.today().isoformat()
100
+
101
+ def track_user(self, student_name):
102
+ """V261: Updates the last_seen for a user in persistent storage"""
103
+ if not student_name: return
104
+ try:
105
+ with self.lock:
106
+ users = self._load_all_users()
107
+ users[student_name] = {
108
+ "last_seen": datetime.datetime.now().isoformat(),
109
+ "id": student_name
110
+ }
111
+ self._save_all_users(users)
112
+ except Exception as e:
113
+ print(f"⚠️ [QUOTA] Failed to track user: {e}")
114
+
115
+ def check_limit(self, student_name):
116
+ """
117
+ Returns:
118
+ (allowed: bool, message: str, current_usage: int, limit: int)
119
+ """
120
+ if not student_name:
121
+ return True, "No name", 0, 9999
122
+
123
+ # V261: Track user on every check
124
+ self.track_user(student_name)
125
+
126
+ # Refresh limits config in case it changed manually
127
+ self._load_limits()
128
+
129
+ if student_name in self.limits_config.get("blocked_users", []):
130
+ return False, "User is blocked", 0, 0
131
+
132
+ limit = self.limits_config.get("students", {}).get(student_name, self.limits_config.get("default_limit", 30))
133
+
134
+ # Admin override or high limit
135
+ if limit < 0:
136
+ return True, "Unlimited", 0, -1
137
+
138
+ today = self.get_today_key()
139
+ with self.lock:
140
+ usage_data = self._load_usage()
141
+ today_usage = usage_data.get(today, {})
142
+ current_count = today_usage.get(student_name, 0)
143
+
144
+ if current_count >= limit:
145
+ return False, f"Daily limit reached ({limit})", current_count, limit
146
+
147
+ return True, "Allowed", current_count, limit
148
+
149
+ def increment_usage(self, student_name):
150
+ if not student_name:
151
+ return
152
+
153
+ today = self.get_today_key()
154
+ with self.lock:
155
+ usage_data = self._load_usage()
156
+ if today not in usage_data:
157
+ usage_data[today] = {}
158
+
159
+ usage_data[today][student_name] = usage_data[today].get(student_name, 0) + 1
160
+ self._save_usage(usage_data)
161
+
162
+ print(f"📊 [QUOTA] Incremented for {student_name}. Date: {today}")
163
+
164
+ # ================= ADMIN METHODS (V261) =================
165
+
166
+ def get_all_users_data(self):
167
+ """Merges all_users (history), limits (config), and usage (today)"""
168
+ all_users = self._load_all_users() # {id: {last_seen...}}
169
+ limits = self.limits_config
170
+ today_usage = self._load_usage().get(self.get_today_key(), {})
171
+
172
+ result = []
173
+ # Merge known users from history
174
+ for uid, udata in all_users.items():
175
+ limit = limits.get("students", {}).get(uid, limits.get("default_limit", 30))
176
+ usage = today_usage.get(uid, 0)
177
+ result.append({
178
+ "id": uid,
179
+ "last_seen": udata.get("last_seen"),
180
+ "limit": limit,
181
+ "usage_today": usage,
182
+ "is_blocked": uid in limits.get("blocked_users", [])
183
+ })
184
+
185
+ # Also include users in limits config who might not be in history yet (?)
186
+ # (Optional, skipping for now to keep it simple)
187
+
188
+ # Sort by most recently seen
189
+ result.sort(key=lambda x: x.get("last_seen", ""), reverse=True)
190
+ return result
191
+
192
+ def set_user_limit(self, user_id, new_limit):
193
+ """Updates the limit for a specific user"""
194
+ with self.lock:
195
+ self._load_limits()
196
+ if "students" not in self.limits_config:
197
+ self.limits_config["students"] = {}
198
+
199
+ try:
200
+ limit_int = int(new_limit)
201
+ self.limits_config["students"][user_id] = limit_int
202
+
203
+ # Save back to file
204
+ with open(LIMITS_FILE, 'w', encoding='utf-8') as f:
205
+ json.dump(self.limits_config, f, indent=4)
206
+ return True, "Updated"
207
+ except Exception as e:
208
+ return False, str(e)
209
+
210
+ # Global instance
211
+ quota_manager = QuotaManager()
refiner.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import logging
3
+
4
+ logger = logging.getLogger("Refiner")
5
+
6
+ class Refiner:
7
+ @staticmethod
8
+ def refine(data: dict) -> dict:
9
+ try:
10
+ if 'sections' in data and isinstance(data['sections'], list):
11
+ for section in data['sections']:
12
+ if 'steps' in section and isinstance(section['steps'], list):
13
+ for step in section['steps']:
14
+ # חשוב: אנחנו מנקים רק את block_math מדולרים
15
+ # את content_mixed אנחנו משאירים עם דולרים כדי שהאפליקציה תרנדר!
16
+ if 'block_math' in step:
17
+ step['block_math'] = Refiner._clean_latex_block(step['block_math'])
18
+
19
+ return data
20
+ except Exception as e:
21
+ logger.error(f"Refinement error: {e}")
22
+ return data
23
+
24
+ @staticmethod
25
+ def _clean_latex_block(tex: str) -> str:
26
+ if not tex or tex == 'null': return ""
27
+ s = str(tex)
28
+ # בבלוק מתמטי נקי לא צריך דולרים
29
+ s = s.replace('$$', '').replace('$', '')
30
+ s = s.replace('-', '−') # מינוס יפה
31
+ return s.strip()
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ python-multipart
4
+ google-generativeai==0.8.3
5
+ pillow
6
+ python-dotenv
7
+ json_repair
8
+ matplotlib
9
+ numpy
10
+ sympy
11
+ edge-tts>=7.0.0
12
+ firebase-admin==6.4.0
13
+ opencv-python-headless
14
+ sse-starlette
smart_solver.py ADDED
@@ -0,0 +1,698 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # smart_solver.py - V7.4 (TYPED SIGNED STEPS + DOMAIN-AWARE CONTRACTS)
2
+ import re
3
+ import hashlib
4
+ import logging
5
+ import sympy
6
+ from sympy import symbols, Eq, solve, sympify, diff, latex, Symbol, simplify, trigsimp
7
+ from sympy import srepr, default_sort_key
8
+ from pydantic import BaseModel, Field
9
+ from typing import List, Dict, Any, Tuple, Union, Optional
10
+ from dataclasses import dataclass
11
+ import copy
12
+ from domain.step_types import StepType, SignedStep
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # ==================== V7.3: TYPED ACTION CONTRACT ====================
17
+
18
+ @dataclass
19
+ class ActionContext:
20
+ """
21
+ V7.3: Typed contract between Orchestrator and Solver.
22
+ Replaces generic dict — unknown fields cause AttributeError, not silent bugs.
23
+ Add new fields here as the system learns new problem types.
24
+ """
25
+ center: Optional[Tuple[float, float]] = None
26
+ radius: Optional[float] = None
27
+ point_a: Optional[Tuple[float, float]] = None
28
+ # Future: triangle_vertices, line_coefficients, etc.
29
+
30
+
31
+ # ==================== V7.2: DETERMINISTIC SIGNATURE ENGINE ====================
32
+
33
+ def sign_step(expression: Union[str, list], problem_id: str, step_id: str) -> SignedStep:
34
+ """
35
+ V7.4: Creates a SHA256-signed algebraic step as a typed SignedStep.
36
+ Uses sympy.srepr for canonical representation to guarantee deterministic hashing.
37
+ Sorts list results to ensure hash stability regardless of SymPy output order.
38
+
39
+ step_type = ALGEBRAIC — ConsistencyGate will validate via sympy.sympify.
40
+ payload = raw expression list or string (semantic truth for validator).
41
+ expression = display string injected into renderer placeholders.
42
+ """
43
+ if isinstance(expression, list):
44
+ # Sort for canonical order (critical for hash determinism)
45
+ parsed_exprs = sorted(
46
+ [sympy.sympify(e) for e in expression],
47
+ key=default_sort_key
48
+ )
49
+ canonical = str([srepr(e) for e in parsed_exprs])
50
+ expr_str = " OR ".join(str(e) for e in parsed_exprs)
51
+ payload = [str(e) for e in parsed_exprs]
52
+ else:
53
+ parsed = sympy.sympify(expression)
54
+ canonical = srepr(parsed)
55
+ expr_str = str(parsed)
56
+ payload = expr_str
57
+
58
+ raw = f"{canonical}{problem_id}{step_id}"
59
+ sig = hashlib.sha256(raw.encode()).hexdigest()
60
+
61
+ logger.info(f"[SIGNATURE] Signed step '{step_id}': hash={sig[:12]}...")
62
+ return SignedStep(
63
+ id=step_id,
64
+ expression=expr_str,
65
+ payload=payload,
66
+ step_type=StepType.ALGEBRAIC,
67
+ hash=sig,
68
+ )
69
+
70
+
71
+ def sign_step_geometry(labels: list, problem_id: str, step_id: str) -> SignedStep:
72
+ """
73
+ V7.4: Typed signer for geometry results (strings, not SymPy expressions).
74
+
75
+ step_type = GEOMETRY — ConsistencyGate validates structurally, NOT via sympify.
76
+ payload = original labels list (semantic truth: list[str] of points/distances).
77
+ expression = display string for renderer injection (pipe-separated, human-readable).
78
+
79
+ CTO note: expression is display-only. payload is the authoritative structure
80
+ the validator uses to verify integrity. Never pass expression to sympy.
81
+ """
82
+ canonical = str(sorted(str(l) for l in labels))
83
+ sig = hashlib.sha256(f"{canonical}{problem_id}{step_id}".encode()).hexdigest()
84
+ expr_str = " | ".join(str(l) for l in labels)
85
+ logger.info(f"[SIGNATURE] Signed geometry step '{step_id}': hash={sig[:12]}...")
86
+ return SignedStep(
87
+ id=step_id,
88
+ expression=expr_str,
89
+ payload=list(labels),
90
+ step_type=StepType.GEOMETRY,
91
+ hash=sig,
92
+ )
93
+
94
+
95
+ def resolve_ast_target(target_step_ref: str, ast_registry: dict) -> str:
96
+ """
97
+ V7.2: Looks up the actual math expression for an AST node ID.
98
+ The Planner works with IDs only — this is where IDs are resolved to real math.
99
+ Raises KeyError if the reference is invalid, preventing silent hallucination.
100
+ """
101
+ if target_step_ref not in ast_registry:
102
+ raise KeyError(
103
+ f"[RESOLVER] AST node '{target_step_ref}' not found in registry. "
104
+ f"Known nodes: {list(ast_registry.keys())}"
105
+ )
106
+ return ast_registry[target_step_ref]
107
+
108
+
109
+ # ==================== V7.2.1: DETERMINISTIC SOLVER DISPATCHER ====================
110
+
111
+ def execute_action(
112
+ action: str,
113
+ expression: str,
114
+ problem_id: str,
115
+ step_id: str,
116
+ ast_variables: list = None,
117
+ context: Optional[ActionContext] = None
118
+ ) -> dict:
119
+ """
120
+ V7.2.1 Wall 2: Deterministic Math Engine.
121
+
122
+ Routes a Planner Enum command to SymPy, executes it deterministically,
123
+ and returns a SHA256-signed step result.
124
+
125
+ Multi-variable handling (CTO note): if the expression has multiple free
126
+ symbols, we solve for the first variable listed in `ast_variables` (from
127
+ AST metadata). If no list is provided, we default to the first free symbol
128
+ found by SymPy — never guess blindly.
129
+
130
+ Args:
131
+ action: One of ComputeAction enum values (string)
132
+ expression: Raw math expression string from AST registry
133
+ problem_id: For hash seeding
134
+ step_id: For hash seeding and tracking
135
+ ast_variables: Ordered list of variable names from build_ast_metadata()
136
+
137
+ Returns:
138
+ dict: {"id": step_id, "hash": "sha256...", "expression": "..."}
139
+ """
140
+ import sympy as sp
141
+
142
+ # Parse expression — treat '=' as LHS - RHS for sp.solve
143
+ normalized = expression.replace('=', '-')
144
+ try:
145
+ expr = sp.sympify(normalized, evaluate=False)
146
+ except Exception as e:
147
+ raise ValueError(f"[SOLVER] Cannot parse expression '{expression}': {e}")
148
+
149
+ # Multi-variable: determine which symbol to solve for
150
+ free_syms = list(expr.free_symbols)
151
+ solve_for = None
152
+ if action == "SOLVE_EQUATION":
153
+ if ast_variables:
154
+ # Use the first AST-declared variable that is actually in the expression
155
+ for var_name in ast_variables:
156
+ candidate = sp.Symbol(var_name)
157
+ if candidate in free_syms:
158
+ solve_for = candidate
159
+ break
160
+ if solve_for is None and free_syms:
161
+ # Fallback: first free symbol (alphabetically for determinism)
162
+ solve_for = sorted(free_syms, key=lambda s: s.name)[0]
163
+ logger.warning(
164
+ f"[SOLVER] No ast_variables hint provided. "
165
+ f"Solving for '{solve_for}' (first free symbol in expression)."
166
+ )
167
+
168
+ # ── V7.3: Geometry Guards ──────────────────────────────────────────────────
169
+ if action == "CALCULATE_DISTANCE" and (
170
+ not context or not context.center or not context.point_a
171
+ ):
172
+ raise ValueError(
173
+ "MissingContextError: CALCULATE_DISTANCE requires context.center and context.point_a"
174
+ )
175
+
176
+ if action == "CALCULATE_SLOPE_AND_LINE" and (
177
+ not context or not context.center or not context.point_a
178
+ ):
179
+ raise ValueError(
180
+ "MissingContextError: CALCULATE_SLOPE_AND_LINE requires context.center and context.point_a"
181
+ )
182
+
183
+ # ── V7.3: Geometry Handler Dispatch ───────────────────────────────────────
184
+ if action == "FIND_AXIS_INTERSECTIONS":
185
+ # Substitute x=0 → solve for y, then y=0 → solve for x
186
+ x_sym, y_sym = sp.Symbol('x'), sp.Symbol('y')
187
+ y_intercepts = sp.solve(expr.subs(x_sym, 0), y_sym)
188
+ x_intercepts = sp.solve(expr.subs(y_sym, 0), x_sym)
189
+ # Build human-readable result list
190
+ result_parts = []
191
+ for yi in y_intercepts:
192
+ result_parts.append(f"(0, {yi})") # x=0
193
+ for xi in x_intercepts:
194
+ result_parts.append(f"({xi}, 0)") # y=0
195
+ result = result_parts if result_parts else ["אין נקודות חיתוך עם הצירים"]
196
+ logger.info(f"[SOLVER] ✅ FIND_AXIS_INTERSECTIONS on '{expression}' → {result}")
197
+ return sign_step_geometry(result, problem_id, step_id)
198
+
199
+ elif action == "CALCULATE_SLOPE_AND_LINE":
200
+ cx, cy = context.center # e.g. (3, 4)
201
+ px, py = context.point_a # e.g. (0, 0) — origin
202
+ # Vertical line guard
203
+ if px == cx:
204
+ line_eq = f"x = {cx}"
205
+ result = [line_eq]
206
+ else:
207
+ slope = sp.Rational(cy - py, cx - px) # exact fraction (no float)
208
+ # y - py = slope*(x - px) → y = slope*x + b
209
+ b = py - slope * px
210
+ x_sym = sp.Symbol('x')
211
+ line_expr = slope * x_sym + b
212
+ # Simplify and return LaTeX-friendly string
213
+ result = [str(sp.simplify(line_expr))]
214
+ logger.info(f"[SOLVER] ✅ CALCULATE_SLOPE_AND_LINE center={context.center} → {result}")
215
+ return sign_step_geometry(result, problem_id, step_id)
216
+
217
+ elif action == "CALCULATE_DISTANCE":
218
+ cx, cy = context.center
219
+ px, py = context.point_a
220
+ # Use exact Rational arithmetic to avoid float comparison ambiguity
221
+ cx_r, cy_r = sp.Rational(cx).limit_denominator(1000), sp.Rational(cy).limit_denominator(1000)
222
+ px_r, py_r = sp.Rational(px).limit_denominator(1000), sp.Rational(py).limit_denominator(1000)
223
+ dist = sp.sqrt((px_r - cx_r)**2 + (py_r - cy_r)**2)
224
+ dist_val = sp.simplify(dist) # SymPy exact value (e.g. 5)
225
+ radius_sym = sp.Rational(context.radius).limit_denominator(1000)
226
+
227
+ # 🚨 [SOP FIX] Guard against NoneType before float cast
228
+ for val in [dist_val, radius_sym]:
229
+ if val is None or val == "null" or val == "":
230
+ raise ValueError("Missing required mathematical argument from LLM. Cannot cast None to float.")
231
+
232
+ # Comparison via Python float (avoids SymPy Float vs int ambiguity)
233
+ dist_float = float(dist_val)
234
+ radius_float = float(radius_sym)
235
+ if abs(dist_float - radius_float) < 1e-9:
236
+ on_circle = "על המעגל"
237
+ elif dist_float < radius_float:
238
+ on_circle = "בתוך המעגל"
239
+ else:
240
+ on_circle = "מחוץ למעגל"
241
+ result = [f"d = {dist_val}", f"r = {context.radius}", on_circle]
242
+ logger.info(f"[SOLVER] ✅ CALCULATE_DISTANCE point={context.point_a} center={context.center} → {result}")
243
+ return sign_step_geometry(result, problem_id, step_id)
244
+
245
+ # ── Legacy Algebraic Action Dispatch ──────────────────────────────────────
246
+ ACTION_MAP = {
247
+ "SOLVE_EQUATION": lambda e: sp.solve(e, solve_for) if solve_for else sp.solve(e),
248
+ "SIMPLIFY": lambda e: [sp.simplify(e)],
249
+ "FACTOR": lambda e: [sp.factor(e)],
250
+ "EXPAND": lambda e: [sp.expand(e)],
251
+ "FIND_DERIVATIVE": lambda e: [sp.diff(e, solve_for or (free_syms[0] if free_syms else sp.Symbol('x')))],
252
+ "FIND_INTEGRAL": lambda e: [sp.integrate(e, solve_for or (free_syms[0] if free_syms else sp.Symbol('x')))],
253
+ "SUBSTITUTE": lambda e: [e],
254
+ }
255
+
256
+ fn = ACTION_MAP.get(action)
257
+ if not fn:
258
+ raise ValueError(f"[SOLVER] Unknown action: '{action}'. Check ComputeAction enum.")
259
+
260
+ try:
261
+ result = fn(expr)
262
+ if not isinstance(result, list):
263
+ result = [result]
264
+ logger.info(f"[SOLVER] ✅ {action} on '{expression}' → {result}")
265
+ except Exception as e:
266
+ raise RuntimeError(f"[SOLVER] SymPy failed on action '{action}': {e}")
267
+
268
+ return sign_step(result, problem_id, step_id)
269
+
270
+
271
+
272
+
273
+ class MathState(BaseModel):
274
+ """ייצוג מתמטי אחיד של מצב הבעיה"""
275
+ equations: List[Any] = Field(default_factory=list) # רשימת משוואות SymPy
276
+ solved_vars: Dict[Any, Any] = Field(default_factory=dict) # משתנים שנפתרו
277
+ original_text: str = ""
278
+
279
+ class Config:
280
+ arbitrary_types_allowed = True
281
+
282
+ def is_solved(self) -> bool:
283
+ # במערכת משוואות MVP: אם יש משתנים פתורים ואין עוד משוואות בלתי פתורות רלוונטיות
284
+ if not self.equations:
285
+ return len(self.solved_vars) > 0
286
+ all_syms = set()
287
+ for eq in self.equations:
288
+ all_syms.update(eq.free_symbols)
289
+ # נפתר אם כל המשתנים מקבלים ערך
290
+ return len(all_syms) > 0 and all(sym in self.solved_vars for sym in all_syms)
291
+
292
+ # ==================== V5.8.0 RULE ENGINE MVP ====================
293
+
294
+ class MathRule:
295
+ name: str = "BaseRule"
296
+
297
+ def is_applicable(self, state: MathState) -> bool:
298
+ return False
299
+
300
+ def apply(self, state: MathState) -> Tuple[MathState, Any]:
301
+ raise NotImplementedError()
302
+
303
+ class RuleSolveLinearSystem(MathRule):
304
+ name = "פתרון מערכת משוואות"
305
+
306
+ def __init__(self):
307
+ self.x, self.y = symbols('x y')
308
+
309
+ def is_applicable(self, state: MathState) -> bool:
310
+ # נזהה מערכת של 2 משוואות עם 2 נעלמים חופשיים
311
+ if len(state.equations) >= 2:
312
+ syms = set()
313
+ for eq in state.equations:
314
+ syms.update(eq.free_symbols)
315
+ if len(syms) == 2:
316
+ return True
317
+ return False
318
+
319
+ def apply(self, state: MathState) -> Tuple[MathState, Any]:
320
+ new_state = copy.copy(state)
321
+ # ניסיון פתרון סימבולי למערכת המשוואות יחד
322
+ eq1 = state.equations[0]
323
+ eq2 = state.equations[1]
324
+
325
+ # V5.8.1: Detailed Steps Mode (Step Granularity)
326
+ # Check if both equations are of the form "sym = expression" and have the same LHS
327
+ if eq1.lhs == eq2.lhs and isinstance(eq1.lhs, Symbol):
328
+ lhs_sym = eq1.lhs
329
+ # Get the other symbol playing the role of x
330
+ rhs_syms = list((eq1.rhs.free_symbols | eq2.rhs.free_symbols) - {lhs_sym})
331
+ if len(rhs_syms) == 1:
332
+ rhs_sym = rhs_syms[0]
333
+ steps = []
334
+
335
+ # 1. Equate
336
+ eq_step = Eq(eq1.rhs, eq2.rhs)
337
+ steps.append({"logic": "נשווה בין שתי המשוואות", "math": latex(eq_step), "rule_id": "solve_linear_system"})
338
+
339
+ # 2. Collect terms
340
+ diff_expr = eq_step.lhs - eq_step.rhs
341
+ c = diff_expr.coeff(rhs_sym)
342
+ d = diff_expr.subs(rhs_sym, 0)
343
+ collected_eq = Eq(c * rhs_sym, -d)
344
+ steps.append({"logic": "נעביר אגפים ונכנס איברים דומים", "math": latex(collected_eq), "rule_id": "solve_linear_system"})
345
+
346
+ # 3. Isolate variable
347
+ x_val = -d / c
348
+ isolated_eq = Eq(rhs_sym, x_val)
349
+ steps.append({"logic": f"נחלק במקדם של {latex(rhs_sym)}", "math": latex(isolated_eq), "rule_id": "solve_linear_system"})
350
+
351
+ # 4. Substitute back
352
+ y_val = eq1.rhs.subs(rhs_sym, x_val)
353
+ # string replacement for substitution display to avoid auto-evaluation collapsing it
354
+ subs_str = latex(eq1.rhs).replace(latex(rhs_sym), f"({latex(x_val)})")
355
+ steps.append({"logic": f"נציב את {latex(rhs_sym)} באחת המשוואות", "math": f"{latex(lhs_sym)} = {subs_str} = {latex(y_val)}", "rule_id": "solve_linear_system"})
356
+
357
+ new_state.solved_vars[rhs_sym] = x_val
358
+ new_state.solved_vars[lhs_sym] = y_val
359
+ new_state.equations = []
360
+ return new_state, steps
361
+
362
+ # V5.8.3 The Ultimate Guard: No step breakdown -> No solve
363
+ return state, "לא ניתן לפרק לצעדים מדורגים."
364
+
365
+ class RuleIsolateVariable(MathRule):
366
+ name = "בידוד משתנה"
367
+ def is_applicable(self, state: MathState) -> bool:
368
+ # האם יש משוואה אחת שניתן לבודד ממנה משתנה שעדיין לא בודד?
369
+ if len(state.equations) == 1:
370
+ return True
371
+ return False
372
+
373
+ def apply(self, state: MathState) -> Tuple[MathState, Any]:
374
+ new_state = copy.copy(state)
375
+ eq = state.equations[0]
376
+ syms = list(eq.free_symbols)
377
+ if not syms: return state, "אין משתנים לבידוד."
378
+ # ננסה לבודד את המשתנה (למשל x)
379
+ sol = solve(eq, syms[0])
380
+ if sol:
381
+ new_state.solved_vars[syms[0]] = sol[0]
382
+ new_state.equations = []
383
+ steps = [{"logic": "נבודד את המשתנה", "math": f"{latex(syms[0])} = {latex(sol[0])}", "rule_id": "isolate_variable"}]
384
+ return new_state, steps
385
+ return state, "לא ניתן לבודד משתנה"
386
+
387
+ class RuleSubstitute(MathRule):
388
+ name = "הצבת משתנה שבודד"
389
+ def is_applicable(self, state: MathState) -> bool:
390
+ return len(state.solved_vars) > 0 and len(state.equations) > 0
391
+
392
+ def apply(self, state: MathState) -> Tuple[MathState, Any]:
393
+ new_state = copy.copy(state)
394
+ # נציב את כל המשתנים הידועים בתוך המשוואות שנותרו
395
+ new_eqs = []
396
+ for eq in state.equations:
397
+ new_eq = eq.subs(state.solved_vars)
398
+ new_eqs.append(new_eq)
399
+ new_state.equations = new_eqs
400
+ # אנחנו לא מוחקים את state.solved_vars כי נרצה לזכור אותם להמשך
401
+ str_vars = ", ".join([f"{latex(k)} = {latex(v)}" for k,v in state.solved_vars.items()])
402
+ steps = [{"logic": "נציב את הערכים הידועים במשוואות", "math": str_vars, "rule_id": "substitute"}]
403
+ return new_state, steps
404
+
405
+
406
+ class StepValidator:
407
+ """
408
+ V6.1 Phase 3: Validation Authority
409
+ Acts as the 'Supreme Court' for LLM proposed ProofGraphs.
410
+ """
411
+
412
+ ALLOWED_CONSTANTS = {'pi', 'E', 'sqrt(2)'} # Removed 'I' as per QA Gate requirements.
413
+
414
+ @classmethod
415
+ def sympy_simplify_tolerance_guard(cls, expr_n, expr_n_plus_1) -> bool:
416
+ """
417
+ V6.1 Phase 4: Tolerance Guard
418
+ Numerical evaluation fallback to handle micro-variances that avoid simplification.
419
+ """
420
+ import random
421
+ try:
422
+ diff_expr = expr_n - expr_n_plus_1
423
+ free_syms = diff_expr.free_symbols
424
+
425
+ # If no free symbols, just evaluate numerically
426
+ if not free_syms:
427
+ val = diff_expr.evalf()
428
+ # 🚨 [SOP FIX] Guard against NoneType before float cast
429
+ if val is None or val == "null" or val == "":
430
+ raise ValueError("Missing required mathematical argument from LLM. Cannot cast None to float.")
431
+ return abs(float(val)) < 1e-9
432
+
433
+ # Sampling: 10 random points
434
+ for _ in range(10):
435
+ subs = {s: random.uniform(0.1, 10.0) for s in free_syms}
436
+ val = diff_expr.evalf(subs=subs)
437
+ # 🚨 [SOP FIX] Guard against NoneType before float cast
438
+ if val is None or val == "null" or val == "":
439
+ raise ValueError("Missing required mathematical argument from LLM. Cannot cast None to float.")
440
+ if abs(float(val)) > 1e-9: # Precision set to 10^-9 as per QA Gate
441
+ return False
442
+ return True
443
+ except Exception:
444
+ return False
445
+
446
+ @classmethod
447
+ def validate_transition(cls, step_n: str, step_n_plus_1: str) -> bool:
448
+ """
449
+ Deterministically verifies the algebraic equivalence between two steps.
450
+ """
451
+ try:
452
+ expr_n = sympify(str(step_n).replace('=', '-'), evaluate=False)
453
+ expr_n_plus_1 = sympify(str(step_n_plus_1).replace('=', '-'), evaluate=False)
454
+
455
+ # Use simplify to check equivalence
456
+ diff = simplify(expr_n - expr_n_plus_1)
457
+
458
+ if diff == 0:
459
+ return True
460
+
461
+ # Fallback for trigonometric identities or complex simplification
462
+ if trigsimp(expr_n) == trigsimp(expr_n_plus_1):
463
+ return True
464
+
465
+ # V6.1 Phase 4: Tolerance Guard (Secondary Check)
466
+ if cls.sympy_simplify_tolerance_guard(expr_n, expr_n_plus_1):
467
+ logger.info(f"🛡️ [VALIDATOR] Tolerance Guard PASSED for {step_n} -> {step_n_plus_1}")
468
+ return True
469
+
470
+ # If equivalence fails, it might be an irreversible action (e.g. squaring).
471
+ return False
472
+
473
+ except Exception as e:
474
+ logger.warning(f"[VALIDATOR] Transition validation error '{step_n}' -> '{step_n_plus_1}': {e}")
475
+ return False
476
+
477
+ @classmethod
478
+ def check_proofgraph_closure(cls, initial_math: str, final_step: str) -> bool:
479
+ """
480
+ Ensures all variables in the original problem are resolved or accounted for,
481
+ and no hallucinations occurred via injected fake constants/variables.
482
+ """
483
+ try:
484
+ initial_exprs = [sympify(str(p).replace('=', '-'), evaluate=False) for p in str(initial_math).split(',')]
485
+ initial_vars = set()
486
+ for ex in initial_exprs:
487
+ initial_vars.update(ex.free_symbols)
488
+
489
+ final_exprs = [sympify(str(p).replace('=', '-'), evaluate=False) for p in str(final_step).split(',')]
490
+ final_vars = set()
491
+ for ex in final_exprs:
492
+ final_vars.update(ex.free_symbols)
493
+
494
+ filtered_final_vars = {str(v) for v in final_vars if not v.is_number and str(v) not in cls.ALLOWED_CONSTANTS}
495
+ filtered_initial_vars = {str(v) for v in initial_vars if not v.is_number and str(v) not in cls.ALLOWED_CONSTANTS}
496
+
497
+ if len(filtered_final_vars - filtered_initial_vars) > 0:
498
+ logger.warning(f"[VALIDATOR] Closure check failed. Leaked vars: {filtered_final_vars - filtered_initial_vars}")
499
+ return False
500
+
501
+ return True
502
+ except Exception as e:
503
+ logger.warning(f"[VALIDATOR] Closure validation error: {e}")
504
+ return False
505
+
506
+ @classmethod
507
+ def evaluate_proposal(cls, initial_math: str, draft_steps: list) -> tuple[float, str]:
508
+ """
509
+ Evaluates the entire Draft ProofGraph.
510
+ Returns (Validation Score [0.0 - 1.0], Error Reason)
511
+ """
512
+ if not draft_steps:
513
+ return 0.0, "EMPTY_DRAFT"
514
+
515
+ valid_transitions = 0
516
+ total_transitions = len(draft_steps)
517
+
518
+ # We assume step 0 is the initial math or a reformatted version of it.
519
+ # We validate transition from step[i] to step[i+1]
520
+ for i in range(len(draft_steps) - 1):
521
+ math_n = draft_steps[i].get('math', '')
522
+ math_n_plus_1 = draft_steps[i+1].get('math', '')
523
+
524
+ if cls.validate_transition(math_n, math_n_plus_1):
525
+ valid_transitions += 1
526
+ else:
527
+ logger.warning(f"[VALIDATOR] Rejecting step {i+1} -> {i+2}: {math_n} to {math_n_plus_1}")
528
+ return 0.0, f"מעבר מתמטי שגוי בין: {math_n} לבין {math_n_plus_1}"
529
+
530
+ score = valid_transitions / total_transitions if total_transitions > 0 else 0.0
531
+
532
+ # Check Closure
533
+ final_step_math = draft_steps[-1].get('math', '')
534
+ if not cls.check_proofgraph_closure(initial_math, final_step_math):
535
+ return 0.0, "סגירת פתרון לא חוקית (נמצאו משתנים לא מאושרים)"
536
+
537
+ return score, ""
538
+
539
+
540
+ def __init__(self, success=False, function=None, derivative=None, steps=None, operator_used=None):
541
+ self.success = success
542
+ self.function = function
543
+ self.derivative = derivative
544
+ self.steps = steps or [] # חובה עבור ה-ProofGraph
545
+ self.operator_used = operator_used
546
+
547
+ class SmartSolver:
548
+ def __init__(self):
549
+ print("✅ 🟢 [BIT-LOG: SmartSolver V263.0] - Algebra Engine Active")
550
+
551
+ def solve(self, context):
552
+ math_input = context.math_input
553
+ category = context.category
554
+ original_text = getattr(context, 'original_text', "").lower()
555
+ grade_num = getattr(context, 'grade_num', 12)
556
+
557
+ print(f"🔍 [BIT-LOG: SOLVER] Analyzing input: '{math_input}'")
558
+ print(f"🔍 [BIT-LOG: SOLVER] Category: {category}, Intent Text: '{original_text[:50]}...'")
559
+
560
+ # --- V5.8.0 Rule Engine MVP (Always First) ---
561
+ print("🔢 [BIT-LOG: SOLVER] Triggering Rule Engine MVP")
562
+ rule_engine_res = self._solve_linear_system(math_input)
563
+ if rule_engine_res and rule_engine_res.success:
564
+ return rule_engine_res
565
+
566
+ # --- ענף 2: חקירת פונקציות (כיתה י' עד י"ב) ---
567
+ # V4.2.12: Anchor regex to start of string to avoid matching 4x + 5y = 120 as 'y = 120'
568
+ is_explicit_func = bool(re.match(r'^(f\(x\)|y|g\(x\))\s*=', math_input, re.IGNORECASE))
569
+ investigation_keywords = ["חקור", "חקירת", "קיצון", "אסימפטוט", "נגזרת", "עליה", "ירידה"]
570
+ has_intent = any(kw in original_text for kw in investigation_keywords)
571
+
572
+ # הנגזרת תופעל רק אם: זו חקירה + יש פונקציה מפורשת + יש כוונה בטקסט
573
+ should_derive = (category == "INVESTIGATION" and is_explicit_func and has_intent and grade_num > 7)
574
+
575
+ if should_derive:
576
+ print("📈 [BIT-LOG: SOLVER] Triggering Calculus Engine (Derivatives)")
577
+ return self._solve_calculus(math_input)
578
+
579
+ # Fallback גנרי
580
+ print(f"🛡️ [BIT-LOG: SOLVER] Generic Algebra Mode. category={category}, intent={has_intent}")
581
+ return SmartResult(success=True, function=math_input, operator_used="ALGEBRA_GENERAL")
582
+
583
+ def _solve_linear_system(self, raw_input):
584
+ """V5.8.0: מנוע קבלת החלטות מבוסס MVP חוקים"""
585
+ try:
586
+ # שלב 1: Parsing
587
+ eq_parts = re.split(r'[,\n]', raw_input)
588
+ parsed_eqs = []
589
+ for part in eq_parts:
590
+ if '=' in part:
591
+ s_a, s_b = part.split('=')
592
+ parsed_eqs.append(Eq(sympify(self._sanitize(s_a)), sympify(self._sanitize(s_b))))
593
+
594
+ if not parsed_eqs:
595
+ return SmartResult(success=False)
596
+
597
+ current_state = MathState(equations=parsed_eqs, original_text=raw_input)
598
+ rules = [RuleSolveLinearSystem(), RuleIsolateVariable(), RuleSubstitute()]
599
+ proof_graph_steps = []
600
+ iteration = 0
601
+ MAX_ITER = 5
602
+
603
+ logger.info(f"🧮 [RULE-ENGINE] Starting resolution for system: {parsed_eqs}")
604
+
605
+ # שלב 2: לולאת החוקים הארכיטקטונית (The Engine)
606
+ while not current_state.is_solved() and iteration < MAX_ITER:
607
+ applied_any = False
608
+ for rule in rules:
609
+ if rule.is_applicable(current_state):
610
+ new_state, step_data = rule.apply(current_state)
611
+ if isinstance(step_data, list):
612
+ for s in step_data:
613
+ proof_graph_steps.append({
614
+ "id": len(proof_graph_steps) + 1,
615
+ "logic": s["logic"],
616
+ "math": s["math"]
617
+ })
618
+ else:
619
+ proof_graph_steps.append({
620
+ "id": len(proof_graph_steps) + 1,
621
+ "logic": rule.name,
622
+ "math": step_data
623
+ })
624
+ current_state = new_state
625
+ applied_any = True
626
+ break # Start rules over with new state
627
+
628
+ if not applied_any:
629
+ logger.warning("🧮 [RULE-ENGINE] No applicable rules found to continue solving.")
630
+ break
631
+
632
+ iteration += 1
633
+
634
+ # יצירת מודל SmartResult עם הוכחה מבוססת חוקים
635
+ if current_state.is_solved():
636
+ print(f"✅ [RULE-ENGINE] Resolved system to state: {current_state.solved_vars}")
637
+ # הוספת צעד סיכום אחרון
638
+ res_str = ", ".join([f"{latex(k)}={latex(v)}" for k,v in current_state.solved_vars.items()])
639
+
640
+ return SmartResult(success=True, function=raw_input, steps=proof_graph_steps, operator_used="RULE_ENGINE_SYSTEM")
641
+ else:
642
+ return SmartResult(success=False)
643
+
644
+ except Exception as e:
645
+ print(f"❌ [BIT-LOG: SOLVER] Algebra Rule Engine Error: {e}")
646
+ return SmartResult(success=False)
647
+
648
+ def _solve_calculus(self, math_input):
649
+ try:
650
+ x = symbols('x')
651
+ # חילוץ הביטוי אחרי ה- '='
652
+ expr_part = math_input.split('=')[-1]
653
+ expr_str = self._sanitize(expr_part)
654
+ logger.info(f"🧮 [TRACE] EXPRESSION SENT TO SYMPY: {expr_str}")
655
+ expr = sympify(expr_str)
656
+ f_prime = diff(expr, x)
657
+
658
+ # חישוב נקודות קיצון
659
+ crit_points = []
660
+ try:
661
+ sols = solve(f_prime, x)
662
+ for s in sols:
663
+ if s is not None and s.is_real:
664
+ y_val = expr.subs(x, s).evalf()
665
+ if y_val is not None:
666
+ crit_points.append(f"({float(s):.2f}, {float(y_val):.2f})")
667
+ except Exception as e:
668
+ logger.warning(f"[SOLVER] Calculus point evaluation failed: {e}")
669
+
670
+ steps = [{"id": 1, "math": f"f'(x)={latex(f_prime)}", "logic": "חישוב נגזרת"}]
671
+ return SmartResult(
672
+ success=True,
673
+ function=f"f(x)={latex(expr)}",
674
+ derivative=f"f'(x)={latex(f_prime)}",
675
+ steps=steps,
676
+ operator_used="DERIVATIVE"
677
+ )
678
+ except Exception as e:
679
+ print(f"❌ [BIT-LOG: SOLVER] Calculus Engine Error: {e}")
680
+ return SmartResult(success=False)
681
+
682
+ def _sanitize(self, text):
683
+ """✅ סנכרון עם מנגנון הניקוי המרכזי (V260.0)"""
684
+ text = text.replace(r'\left', '').replace(r'\right', '')
685
+ while r'\frac' in text:
686
+ text = re.sub(r'\\frac\s*\{(.*?)\}\{(.*?)\}', r'(\1)/(\2)', text)
687
+
688
+ text = re.sub(r'(sin|cos|tan)\s+([a-zA-Z0-9]+)', r'\1(\2)', text)
689
+ text = text.replace(r'\sin', 'sin').replace(r'\cos', 'cos').replace(r'\tan', 'tan')
690
+ text = text.replace('^', '**')
691
+
692
+ # ✅ V260.0: Robust implicit multiplication
693
+ text = re.sub(r'(\d)([a-zA-Z(])', r'\1*\2', text)
694
+ text = re.sub(r'\)([a-zA-Z0-9(])', r')*\1', text)
695
+ text = re.sub(r'(?<![a-zA-Z])([a-zA-Z])\(', r'\1*(', text)
696
+
697
+ allowed = "0123456789+-*/().=xsincoabslpqrtABSpi eylog"
698
+ return ''.join(c for c in text if c.lower() in allowed).strip()
strategy_manager.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import topic_taxonomy
2
+ import micro_prompts
3
+ import validators
4
+ import asyncio
5
+ import json
6
+ import cost_tracker
7
+ import re
8
+ import logging
9
+ from utils.safe_json import safe_extract_json # V1.0: Canonical extractor (replaces local duplicate)
10
+ import prompts
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class StrategyManager:
16
+ def __init__(self, llm_model):
17
+ self.llm = llm_model
18
+ self.max_retries = 2
19
+
20
+ async def solve_with_strategy(self, problem_text, data_anchor, grade="10", image_data=None, parent_category=None, ambiguity_warning=False, intent=None, intent_contract=None, proof_graph_steps_count=1, student_name="נסיך", student_gender="M"):
21
+ """
22
+ V3.1.2: Added support for adaptive failure (Soft Recovery).
23
+ V5.8.2: Dynamic Token Budget added via proof_graph_steps_count.
24
+ """
25
+ self.grade = grade # V4.2.11: Persist grade for prompt building
26
+ topic_id = topic_taxonomy.detect_topic(problem_text, grade)
27
+ detected_category = topic_taxonomy.get_topic_category(topic_id)
28
+ category = detected_category if detected_category != "GENERAL" else (parent_category or "GENERAL")
29
+
30
+ image_pages = []
31
+ if image_data:
32
+ try:
33
+ from ocr_strip_engine import paginate_image
34
+ image_pages = paginate_image(image_data)
35
+ except Exception as e:
36
+ logger.exception("CRITICAL FLOW ERROR")
37
+ logger.error(f"Pagination failed: {e}")
38
+
39
+ # V4.3: Single Pass (No Retries)
40
+ try:
41
+ strategy_config = self._get_strategy(topic_id)
42
+ prompt = self._build_prompt(topic_id, data_anchor, strategy_config, problem_text, category, grade, student_name, student_gender)
43
+
44
+ # V4.2 Intent Lockdown (Iron Law #2)
45
+ if intent_contract and intent_contract.get("status") != "unconstrained":
46
+ lockdown_note = f"""
47
+ 📢 [V4.2] PEDAGOGICAL LOCKDOWN:
48
+ - Intent: {intent}
49
+ - Max Variables: {intent_contract.get('max_variables', 'N/A')}
50
+ - Forbidden: {", ".join(intent_contract.get('forbidden_strategies', []))}
51
+ - REQUIRED: Use ONLY basic algebraic steps. DO NOT use advanced functions or calculus.
52
+ """
53
+ prompt = lockdown_note + "\n" + prompt
54
+ if data_anchor.get("function_equations"):
55
+ prompt = f"TARGET FUNCTION: {data_anchor['function_equations'][0]}\n\n" + prompt
56
+
57
+ # V3.1.2: Soft Recovery Prompt Injection
58
+ if ambiguity_warning:
59
+ soft_recovery_note = """
60
+ System Prompt Override: הוסף לתחילת ההסבר שלך את ההערה הבאה בנימה ידידותית: 'היי! הצילום היה קצת לא ברור, אבל נראה לי שהתכוונת לביטוי הזה. אם התכוונת למשהו אחר, פשוט צלם שוב מקרוב!' - המשך להסביר את השלבים כרגיל.
61
+ """
62
+ prompt = soft_recovery_note + "\n" + prompt
63
+
64
+ prompt += f"\n\n🎯 MISSION: Solve ONLY part: {problem_text}"
65
+ llm_response = await self._call_llm(prompt, image_data, category, image_pages, proof_graph_steps_count)
66
+
67
+ # V5.8.2: Guard against raw list responses
68
+ if isinstance(llm_response, list):
69
+ llm_response = {"steps": llm_response, "is_valid": True, "confidence_score": 1.0}
70
+
71
+ return {
72
+ "topic": topic_id,
73
+ "category": category,
74
+ "llm_response": llm_response,
75
+ "validated": llm_response.get("is_valid", True),
76
+ "confidence": llm_response.get("confidence_score", 1.0)
77
+ }
78
+ except asyncio.CancelledError:
79
+ logger.warning("V4.3 Single Pass Cancelled (Client Disconnected)")
80
+ return {
81
+ "topic": topic_id,
82
+ "category": category,
83
+ "llm_response": {"solution_markdown": "בוטל על ידי המשתמש", "is_valid": False, "confidence_score": 0.0},
84
+ "validated": False
85
+ }
86
+ except Exception as e:
87
+ logger.error(f"V4.3 Single Pass Failed: {e}")
88
+ return {
89
+ "topic": topic_id,
90
+ "category": category,
91
+ "llm_response": {"solution_markdown": "המורה נתקלה בקושי טכני.", "is_valid": False, "confidence_score": 0.0},
92
+ "validated": False
93
+ }
94
+
95
+ def _get_strategy(self, topic_id):
96
+ return {"has_micro_prompt": topic_id in micro_prompts.MICRO_PROMPTS, "has_validator": topic_id in validators.VALIDATORS}
97
+
98
+ def _build_prompt(self, topic_id, data_anchor, strategy_config, problem_text, category, grade, student_name, student_gender):
99
+ # V7.3 Hybrid Mode: Always use Specialist Prompt for the "Solution Skin"
100
+ specialist = prompts.get_specialist_prompt(
101
+ category=category,
102
+ problem_text=problem_text,
103
+ solver_hint="Hybrid Navigation Mode",
104
+ grade=grade,
105
+ student_name=student_name,
106
+ student_gender=student_gender,
107
+ data_anchor=data_anchor
108
+ )
109
+
110
+ if strategy_config["has_micro_prompt"]:
111
+ try:
112
+ focus = micro_prompts.get_micro_prompt(topic_id, data_anchor, grade=self.grade)
113
+ return f"{focus}\n\n{specialist}"
114
+ except:
115
+ pass
116
+
117
+ return specialist
118
+ return micro_prompts.get_general_prompt(data_anchor)
119
+
120
+ async def _call_llm(self, prompt, image_data, category, image_pages, proof_graph_steps_count=1):
121
+ # V8.5: No More Patchwork. Use centralized V4.3.0 standard.
122
+ v430_instruction = prompts.get_master_prompt_v430()
123
+ prompt += v430_instruction
124
+
125
+ from google.generativeai.types import GenerationConfig
126
+ import asyncio
127
+
128
+ # V5.8.3: Pre-release Hardening (Increased Budget for Preamble)
129
+ # V8.6: Golden Merge - Increased to 8192 to support high-density Micro-Stepping
130
+ max_tokens = 8192
131
+ logger.info(f"🪙 [BUDGET] Dynamic Token Budget allocated: {max_tokens} tokens for {proof_graph_steps_count} steps.")
132
+
133
+ gen_config = GenerationConfig(
134
+ temperature=0.0,
135
+ top_p=0.1,
136
+ top_k=1,
137
+ max_output_tokens=max_tokens,
138
+ response_mime_type="application/json"
139
+ )
140
+
141
+ try:
142
+ if image_pages:
143
+ payload = [f"Images are sequential. Image 1 is Header.\n{prompt}"] + image_pages
144
+ logger.info(f"🧠 [TRACE] PROMPT SENT TO LLM: {payload[0]}")
145
+ # V8.5: True Async Cancellation. Using current_task to handle disconnects.
146
+ response = await asyncio.wait_for(
147
+ self.llm.generate_content_async(payload, generation_config=gen_config),
148
+ timeout=60.0
149
+ )
150
+ else:
151
+ logger.info(f"🧠 [TRACE] PROMPT SENT TO LLM: {prompt}")
152
+ response = await asyncio.wait_for(
153
+ self.llm.generate_content_async(prompt, generation_config=gen_config),
154
+ timeout=30.0
155
+ )
156
+ except asyncio.CancelledError:
157
+ # HARD STOP: Client disconnected. Active cancellation is handled by wait_for + generate_content_async
158
+ logger.warning("📉 [V8.5] LLM Call actively CANCELLED due to client disconnect.")
159
+ # Repropagate to allow parent orchestrator to catch it
160
+ raise
161
+ except Exception as e:
162
+ logger.error(f"Error in _call_llm generation: {e}")
163
+ raise
164
+
165
+ raw_text = response.text
166
+ logger.info(f"📦 [TRACE] RAW RESPONSE RECEIVED: {raw_text}")
167
+
168
+ # V8.5.1: Convert UsageMetadata (Custom Class) to dict to avoid serialization error
169
+ usage = getattr(response, 'usage_metadata', None)
170
+ usage_dict = {}
171
+ if usage:
172
+ usage_dict = {
173
+ "prompt_token_count": getattr(usage, 'prompt_token_count', 0),
174
+ "candidates_token_count": getattr(usage, 'candidates_token_count', 0),
175
+ "total_token_count": getattr(usage, 'total_token_count', 0)
176
+ }
177
+
178
+ parsed = self._parse_v4_json(raw_text)
179
+ return {**parsed, "usage_metadata": usage_dict}
180
+
181
+ def _parse_v4_json(self, raw_text):
182
+ # V4.7 → V1.0: Use canonical safe_extract_json (logs RAW, LaTeX shield, json_repair, fail-closed)
183
+ if "{" not in raw_text and "[" not in raw_text:
184
+ return {"solution_markdown": "שגיאת מבנה", "is_valid": False, "confidence_score": 0.1}
185
+ result = safe_extract_json(raw_text, caller="STRATEGY_MANAGER")
186
+ if isinstance(result, dict) and result.get("logic_error"):
187
+ return {"solution_markdown": "המורה נכשלה בפירוש הפתרון", "is_valid": False, "confidence_score": 0.1}
188
+ return result
189
+
190
+ def _validate(self, topic_id, llm_response, data_anchor): return {"valid": True, "error": None}
191
+
192
+ async def generate_raw(self, system_prompt: str, user_prompt: str) -> str:
193
+ """
194
+ V6.1: Direct raw LLM call for ProposalEngine. Unconstrained output.
195
+ """
196
+ from google.generativeai.types import GenerationConfig
197
+ import asyncio
198
+
199
+ full_prompt = f"{system_prompt}\n\nUser Request: {user_prompt}"
200
+
201
+ gen_config = GenerationConfig(
202
+ temperature=0.2, # Slight creativity needed to draft proofs, but still grounded
203
+ top_p=0.8,
204
+ )
205
+
206
+ logger.info(f"🧠 [PROPOSAL-GATEWAY] Sending PROMPT to LLM.")
207
+ try:
208
+ response = await asyncio.wait_for(
209
+ asyncio.to_thread(self.llm.generate_content, full_prompt, generation_config=gen_config),
210
+ timeout=45.0
211
+ )
212
+ return getattr(response, 'text', '')
213
+ except Exception as e:
214
+ logger.error(f"❌ [PROPOSAL-GATEWAY] LLM Request failed: {e}")
215
+ raise e