Spaces:
Runtime error
Runtime error
Commit ·
9d29c62
0
Parent(s):
Fix: Clean production deployment with sse-starlette
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +5 -0
- .gitignore +40 -0
- Dockerfile +31 -0
- README.md +37 -0
- all_users.json +34 -0
- audio_generator.py +267 -0
- config.py +39 -0
- cost_tracker.py +82 -0
- curriculum_engine.py +115 -0
- curriculum_israel.py +579 -0
- debug_llm_payload.py +42 -0
- domain/curriculum_classifier.py +94 -0
- domain/math_normalizer.py +38 -0
- domain/math_validator.py +282 -0
- domain/ontology.py +37 -0
- domain/pedagogical_renderer.py +264 -0
- domain/processing_strategy.py +7 -0
- domain/proposal_engine.py +189 -0
- domain/risk_engine.py +66 -0
- domain/schemas.py +19 -0
- domain/semantic_bank.py +366 -0
- domain/step_types.py +71 -0
- domain/telemetry.py +177 -0
- domain/validator.py +148 -0
- explanation_math_firewall.py +60 -0
- find_models.py +8 -0
- firebase_manager.py +68 -0
- fix_welcome_audio.py +35 -0
- generate_new_welcome.py +102 -0
- gibberish_detector.py +240 -0
- implementation_plan.md +0 -0
- logs/usage.jsonl +0 -0
- main.py +220 -0
- math_engine.py +154 -0
- math_intent_detector.py +132 -0
- math_parser.py +183 -0
- math_sanitizer.py +94 -0
- memory_service.py +155 -0
- micro_prompts.py +200 -0
- ocr_strip_engine.py +290 -0
- orchestrator.py +0 -0
- pedagogical_builder.py +850 -0
- problem_understanding.py +152 -0
- prompts.py +815 -0
- proof_graph.py +41 -0
- quota_system.py +211 -0
- refiner.py +31 -0
- requirements.txt +14 -0
- smart_solver.py +698 -0
- strategy_manager.py +215 -0
.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
|