diff --git a/.env.example b/.env.example
deleted file mode 100644
index 6964366142b75192c5c126b34554df5347036032..0000000000000000000000000000000000000000
--- a/.env.example
+++ /dev/null
@@ -1,5 +0,0 @@
-GOOGLE_API_KEY=your-gemini-api-key-here
-
-# OCR Mode: development = Stitch & Strip engine (new, single-pass)
-# production = Triple-Pass legacy (safe, proven)
-OCR_STRIP_MODE=development
diff --git a/.gcloudignore b/.gcloudignore
deleted file mode 100644
index c38c377617a6bf0e972f4fb618b7c7f7bc61b336..0000000000000000000000000000000000000000
--- a/.gcloudignore
+++ /dev/null
@@ -1,16 +0,0 @@
-.gcloudignore
-.git
-.gitignore
-venv/
-.venv/
-.venv_fix/
-__pycache__/
-*.pyc
-*.pyo
-*.pyd
-.pytest_cache/
-tests/
-test_results/
-logs/
-server.log
-deploy_hf/
diff --git a/.gitignore b/.gitignore
deleted file mode 100644
index b3b91b31ea5e55a2322a4334fc1606d4b030ea5a..0000000000000000000000000000000000000000
--- a/.gitignore
+++ /dev/null
@@ -1,9 +0,0 @@
-__pycache__/
-*.pyc
-*.pyo
-serviceAccountKey*.json
-*credentials.json
-.env
-output_locus.txt
-*.log
-.DS_Store
diff --git a/Dockerfile b/Dockerfile
index 86392be9c8898bb3823a21789e6bae3757e2628d..16d8c5bf250720a03f2b0f072cbd1b7696eaba07 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,31 +1,22 @@
-# Dockerfile - V8.0 FIXED (Uvicorn + SymPy Support)
-FROM python:3.11-slim
+FROM python:3.10-slim
-WORKDIR /app
+RUN apt-get update && apt-get install -y wget build-essential && rm -rf /var/lib/apt/lists/*
-# התקנת תלויות מערכת (פונטים לגרפים)
-RUN apt-get update && apt-get install -y \
- libgl1 \
- libglib2.0-0 \
- fonts-dejavu-core \
- fontconfig \
- && rm -rf /var/lib/apt/lists/* \
- && fc-cache -f -v
+RUN useradd -m -u 1000 user
+USER user
+ENV HOME=/home/user \
+ PATH=/home/user/.local/bin:$PATH
-# התקנת ספריות פייתון
-COPY requirements.txt .
-RUN pip install --no-cache-dir -r requirements.txt
+WORKDIR $HOME/app
-# העתקת קבצי האפליקציה
-COPY . .
+COPY --chown=user requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
-# יצירת משתמש לא-root (דרישת אבטחה)
-RUN useradd -m -u 1000 user
-USER user
-ENV PATH="/home/user/.local/bin:$PATH"
+# Download DictaLM 2.0 Q4_K_M GGUF
+RUN wget -q -O model.gguf "https://huggingface.co/dicta-il/dictalm2.0-instruct-GGUF/resolve/main/dictalm2.0-instruct.Q4_K_M.gguf"
-# חשיפת פורט 7860
-EXPOSE 7860
+COPY --chown=user . .
-# --- התיקון הקריטי: הרצה עם uvicorn ---
-CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
\ No newline at end of file
+# Run llama-cpp-python server
+# Port 7860 is required for HF Spaces
+CMD ["python", "-m", "llama_cpp.server", "--model", "model.gguf", "--host", "0.0.0.0", "--port", "7860", "--n_ctx", "4096", "--chat_format", "llama-2"]
diff --git a/README.md b/README.md
index 46002a38ab1cb6e17cbdb60250f26abdfe8655ca..0583ea13f0b0b45b400c7c24997d359effba4616 100644
--- a/README.md
+++ b/README.md
@@ -1,37 +1,9 @@
---
-title: BuddyMath
+title: BuddyMath Dicta API
emoji: 🧮
colorFrom: blue
-colorTo: green
+colorTo: purple
sdk: docker
-pinned: false
+app_port: 7860
---
-
-# BuddyMath Server V3.1
-
-מורה פרטי AI למתמטיקה לתלמידים ישראליים 🇮🇱
-
-## API Endpoints
-
-| Endpoint | Method | Description |
-|----------|--------|-------------|
-| `/` | GET | Health check |
-| `/health` | GET | Server status |
-| `/solve_stream` | POST | פתרון בעיה (SSE) |
-
-## Modes
-
-- `solve` - פתור לי
-- `teach_me` - למד אותי
-- `check` - בדוק אותי
-
-## Parameters
-
-```
-image: File (required)
-grade: string (required)
-mode: string (default: "solve")
-student_name: string (default: "תלמיד/ה")
-student_gender: string (default: "male")
-user_note: string (optional)
-```
+API Server for DictaLM 2.0
diff --git a/all_users.json b/all_users.json
deleted file mode 100644
index bf0860eb98eabb9d00d726bc0ce0f523414f4ee3..0000000000000000000000000000000000000000
--- a/all_users.json
+++ /dev/null
@@ -1,34 +0,0 @@
-{
- "test_user_123": {
- "last_seen": "2026-02-15T12:49:04.304849",
- "id": "test_user_123"
- },
- "\u05d3\u05d5\u05ea\u05df": {
- "last_seen": "2026-03-08T15:18:08.252094",
- "id": "\u05d3\u05d5\u05ea\u05df"
- },
- "stress_user_1": {
- "last_seen": "2026-02-27T12:03:10.171108",
- "id": "stress_user_1"
- },
- "stress_user_2": {
- "last_seen": "2026-02-27T12:03:10.221285",
- "id": "stress_user_2"
- },
- "stress_user_3": {
- "last_seen": "2026-02-27T12:03:10.263698",
- "id": "stress_user_3"
- },
- "stress_user_4": {
- "last_seen": "2026-02-27T12:03:10.332969",
- "id": "stress_user_4"
- },
- "stress_user_0": {
- "last_seen": "2026-02-27T12:03:10.382946",
- "id": "stress_user_0"
- },
- "diagnostic": {
- "last_seen": "2026-02-28T11:19:56.770735",
- "id": "diagnostic"
- }
-}
\ No newline at end of file
diff --git a/analytics.py b/analytics.py
deleted file mode 100644
index 566cfb0a2070856c429f62819ecac6fe6625e146..0000000000000000000000000000000000000000
--- a/analytics.py
+++ /dev/null
@@ -1,77 +0,0 @@
-import datetime
-import logging
-from firebase_admin import firestore
-from firebase_manager import firebase_manager
-
-logger = logging.getLogger("HamoraServer")
-
-class AnalyticsManager:
- """
- Manages AI Assessment Telemetry and Weekly Reports.
- """
- def _get_db(self):
- return firebase_manager.get_db()
-
- def update_weekly_analytics(self, uid: str, assessment: dict):
- """
- Updates the weekly analytics document for a user using atomic operations.
- Runs silently.
- """
- if not uid or not assessment:
- return
-
- db = self._get_db()
- if not db:
- logger.error("❌ [ANALYTICS] Firestore DB not available.")
- return
-
- try:
- # 1. Parse assessment data
- primary_skill = assessment.get("primary_skill", "Unknown")
- sub_skill = assessment.get("sub_skill", "")
- mastery_score = assessment.get("mastery_score", 0)
- parent_note = assessment.get("parent_note", "")
-
- # Ensure score is an integer
- try:
- mastery_score = int(mastery_score)
- except (ValueError, TypeError):
- mastery_score = 0
-
- # 2. Compute week_id (e.g., week_11_2026)
- today = datetime.date.today()
- year, week_num, _ = today.isocalendar()
- week_id = f"week_{week_num:02d}_{year}"
-
- # 3. Construct the document reference
- # Path: users/{uid}/analytics/{week_id}
- doc_ref = db.collection("users").document(uid).collection("analytics").document(week_id)
-
- # 4. Construct payload with FieldValue operations for atomic updates
- # Create the nested skill payload
- skill_payload = {
- "sum_scores": firestore.Increment(mastery_score),
- "count": firestore.Increment(1)
- }
- if sub_skill:
- skill_payload["sub_skills"] = firestore.ArrayUnion([sub_skill])
-
- # Combine into final payload
- payload = {
- "total_exercises": firestore.Increment(1),
- f"skills_data.{primary_skill}": skill_payload,
- "last_updated": firestore.SERVER_TIMESTAMP
- }
-
- if parent_note:
- payload["parent_notes"] = firestore.ArrayUnion([parent_note])
-
- # 5. Set with merge=True
- doc_ref.set(payload, merge=True)
- logger.info(f"📊 [ANALYTICS] Successfully updated telemetry for {uid} in {week_id}.")
-
- except Exception as e:
- logger.error(f"❌ [ANALYTICS] Error updating analytics for {uid}: {e}")
-
-# Global instance
-analytics_manager = AnalyticsManager()
diff --git a/audio_generator.py b/audio_generator.py
deleted file mode 100644
index 7c3db3279b0155e3ae19f8130eba285864a1858e..0000000000000000000000000000000000000000
--- a/audio_generator.py
+++ /dev/null
@@ -1,267 +0,0 @@
-# audio_generator.py - V273.0 (Google Cloud TTS - High Quality Hebrew)
-import asyncio
-import base64
-import os
-import tempfile
-import logging
-
-# Configure Logging
-logger = logging.getLogger(__name__)
-
-# ═══════════════════════════════════════════════════════════════
-# 🎙️ Google Cloud TTS Configuration
-# ═══════════════════════════════════════════════════════════════
-#
-# קולות עבריים זמינים:
-# - he-IL-Wavenet-A (נקבה, איכות גבוהה) ⭐ מומלץ
-# - he-IL-Wavenet-B (זכר, איכות גבוהה)
-# - he-IL-Standard-A (נקבה, איכות רגילה)
-# - he-IL-Standard-B (זכר, איכות רגילה)
-#
-# Free Tier: 1 מיליון תווים/חודש (WaveNet: 1M, Standard: 4M)
-# ═══════════════════════════════════════════════════════════════
-
-GOOGLE_VOICE_NAME = "he-IL-Wavenet-A" # Female, high quality
-GOOGLE_LANGUAGE_CODE = "he-IL"
-SPEAKING_RATE = 0.95 # מעט יותר איטי לבהירות
-PITCH = 1.0 # גובה קול רגיל
-
-# Fallback to edge-tts if Google Cloud not configured
-USE_EDGE_TTS_FALLBACK = True
-EDGE_TTS_VOICE = "he-IL-HilaNeural"
-
-from firebase_manager import firebase_manager # V261.17
-
-
-def _is_google_cloud_configured() -> bool:
- """בדיקה אם Google Cloud מוגדר"""
- # Option 1: Environment variable
- if os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"):
- return True
- # Option 2: Check for credentials file in common locations
- common_paths = [
- "/app/google-credentials.json",
- "./google-credentials.json",
- os.path.expanduser("~/.config/gcloud/application_default_credentials.json")
- ]
- for path in common_paths:
- if os.path.exists(path):
- os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = path
- return True
- return False
-
-
-async def _generate_with_google_cloud(text: str, output_path: str) -> bool:
- """
- יצירת אודיו עם Google Cloud TTS
- מחזיר True אם הצליח, False אם נכשל
- """
- try:
- from google.cloud import texttospeech
-
- # Create client
- client = texttospeech.TextToSpeechClient()
-
- # Build the voice request
- voice = texttospeech.VoiceSelectionParams(
- language_code=GOOGLE_LANGUAGE_CODE,
- name=GOOGLE_VOICE_NAME,
- )
-
- # Select the audio format
- audio_config = texttospeech.AudioConfig(
- audio_encoding=texttospeech.AudioEncoding.MP3,
- speaking_rate=SPEAKING_RATE,
- pitch=PITCH,
- )
-
- # Build the synthesis input
- synthesis_input = texttospeech.SynthesisInput(text=text)
-
- # Perform the text-to-speech request
- logger.info(f"🎙️ Google Cloud TTS: Generating audio for {len(text)} chars...")
-
- # Run in thread pool to not block async
- loop = asyncio.get_running_loop()
- response = await loop.run_in_executor(
- None,
- lambda: client.synthesize_speech(
- input=synthesis_input,
- voice=voice,
- audio_config=audio_config
- )
- )
-
- # Write the audio content to file
- with open(output_path, "wb") as out:
- out.write(response.audio_content)
-
- logger.info(f"✅ Google Cloud TTS: Audio saved to {output_path}")
- return True
-
- except ImportError:
- logger.warning("⚠️ google-cloud-texttospeech not installed. Run: pip install google-cloud-texttospeech")
- return False
- except Exception as e:
- logger.error(f"❌ Google Cloud TTS failed: {e}")
- return False
-
-
-async def _generate_with_edge_tts(text: str, output_path: str) -> bool:
- """
- יצירת אודיו עם edge-tts (Fallback)
- """
- try:
- import edge_tts
-
- logger.info(f"🎙️ Edge TTS (Fallback): Generating audio...")
- communicate = edge_tts.Communicate(text, EDGE_TTS_VOICE)
- await communicate.save(output_path)
-
- logger.info(f"✅ Edge TTS: Audio saved to {output_path}")
- return True
-
- except Exception as e:
- logger.error(f"❌ Edge TTS failed: {e}")
- return False
-
-
-async def generate_teacher_audio(text: str, output_path: str = None) -> str:
- """
- V273.0: יצירת אודיו עם Google Cloud TTS (איכות גבוהה)
-
- מנסה קודם Google Cloud TTS, אם לא מוגדר/נכשל → edge-tts fallback
-
- Returns:
- - Public URL (if Firebase upload success)
- - Base64 string (fallback)
- - None (if all failed)
- """
- try:
- if not text:
- return None
-
- # Clean text for TTS (remove emojis and special chars that cause issues)
- clean_text = _clean_text_for_tts(text)
-
- if not clean_text:
- return None
-
- logger.info(f"🎙️ TTS Request: {clean_text[:50]}...")
-
- # Determine output path
- if output_path:
- os.makedirs(os.path.dirname(output_path), exist_ok=True)
- final_path = output_path
- else:
- timestamp = int(asyncio.get_event_loop().time() * 1000)
- final_path = os.path.join(tempfile.gettempdir(), f"audio_{timestamp}.mp3")
-
- # Try Google Cloud TTS first
- success = False
- if _is_google_cloud_configured():
- success = await _generate_with_google_cloud(clean_text, final_path)
- else:
- logger.info("ℹ️ Google Cloud not configured, using Edge TTS")
-
- # Fallback to edge-tts
- if not success and USE_EDGE_TTS_FALLBACK:
- success = await _generate_with_edge_tts(clean_text, final_path)
-
- if not success:
- logger.error("❌ All TTS methods failed")
- return None
-
- # Try Firebase Upload
- try:
- blob_name = f"audio/{os.path.basename(final_path)}"
- loop = asyncio.get_running_loop()
-
- public_url = await loop.run_in_executor(
- None,
- lambda: firebase_manager.upload_file(final_path, blob_name)
- )
-
- if public_url:
- logger.info(f"☁️ Firebase URL: {public_url}")
- # Clean up local file
- if not output_path:
- os.remove(final_path)
- return public_url
- except Exception as fb_err:
- logger.warning(f"⚠️ Firebase upload failed ({fb_err}). Using Base64.")
-
- # Fallback: Return Base64
- with open(final_path, "rb") as audio_file:
- audio_bytes = audio_file.read()
- audio_base64 = base64.b64encode(audio_bytes).decode('utf-8')
-
- # Clean up temp file
- if not output_path:
- os.remove(final_path)
-
- return audio_base64
-
- except Exception as e:
- logger.error(f"❌ TTS Generation Failed: {e}")
- return None
-
-
-def _clean_text_for_tts(text: str) -> str:
- """
- ניקוי טקסט לפני TTS - הסרת אימוג'ים וסימנים בעייתיים
- """
- import re
-
- if not text:
- return ""
-
- # Remove emojis
- emoji_pattern = re.compile("["
- u"\U0001F600-\U0001F64F" # emoticons
- u"\U0001F300-\U0001F5FF" # symbols & pictographs
- u"\U0001F680-\U0001F6FF" # transport & map symbols
- u"\U0001F1E0-\U0001F1FF" # flags
- u"\U00002702-\U000027B0"
- u"\U000024C2-\U0001F251"
- "]+", flags=re.UNICODE)
-
- clean = emoji_pattern.sub('', text)
-
- # Remove multiple spaces
- clean = re.sub(r'\s+', ' ', clean)
-
- # Remove LaTeX remnants that might have slipped through
- clean = clean.replace('$', '').replace('\\', '')
-
- return clean.strip()
-
-
-# ═══════════════════════════════════════════════════════════════
-# 🧪 Testing
-# ═══════════════════════════════════════════════════════════════
-
-if __name__ == "__main__":
- async def main():
- text = """
- איזה יופי של תרגיל! היינו צריכים למצוא את נקודות הקיצון של הפונקציה.
- השתמשנו בנגזרת ראשונה כדי למצוא איפה השיפוע מתאפס.
- הטריק לזכור - נגזרת אפס תמיד מסמנת נקודת קיצון אפשרית.
- כל הכבוד על ההתמדה!
- """
-
- print(f"🎙️ Testing TTS...")
- print(f"📝 Text length: {len(text)} chars")
- print(f"☁️ Google Cloud configured: {_is_google_cloud_configured()}")
-
- result = await generate_teacher_audio(text)
-
- if result:
- if result.startswith("http"):
- print(f"✅ Got URL: {result}")
- else:
- print(f"✅ Got Base64: {len(result)} chars")
- else:
- print("❌ TTS failed")
-
- asyncio.run(main())
\ No newline at end of file
diff --git a/backend/__init__.py b/backend/__init__.py
deleted file mode 100644
index 7c7ae39d6c465edc3f4bb484a37c486d5a7ca61a..0000000000000000000000000000000000000000
--- a/backend/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-# backend/__init__.py
-# BuddyMath V2.0 backend package
diff --git a/backend/extractor.py b/backend/extractor.py
deleted file mode 100644
index 0510466776f2ccd68ee300906aa6a23699249480..0000000000000000000000000000000000000000
--- a/backend/extractor.py
+++ /dev/null
@@ -1,127 +0,0 @@
-# backend/extractor.py
-# BuddyMath V2.0 — Stage 1: Extraction LLM
-#
-# RULES (from spec):
-# 1. Extract ONLY. Never solve.
-# 2. If a value is unclear from OCR/text → return null. NO guessing.
-# 3. `find` must list what the question asks, not intermediate steps.
-# 4. `constraints` must list verbal/diagram constraints verbatim.
-# ─────────────────────────────────────────────────────────────────────────
-
-from __future__ import annotations
-
-import json
-import logging
-import re
-from typing import Any, Optional
-
-import google.generativeai as genai
-
-from backend.math_schema import MathProblemSchema
-
-logger = logging.getLogger(__name__)
-
-# ── Extraction-only system prompt ────────────────────────────────────────
-_EXTRACTION_SYSTEM_PROMPT = """
-You are a MATH DATA EXTRACTOR. Your ONLY job is to parse the problem and output a strict JSON object.
-
-ABSOLUTE RULES:
-1. DO NOT solve the problem. DO NOT calculate anything.
-2. If a value is unclear, ambiguous, or the OCR image is blurry → use JSON null for that field.
- null ≠ "field not in problem". null = "field exists in problem but value is unreadable/unclear".
-3. Every field in `given` that the problem mentions must appear — even if its value is null.
-4. `find` must list exactly what the question asks to find/prove/calculate.
-5. `constraints` must list verbal/diagram rules (e.g. "M is the midpoint of AD").
-
-OUTPUT FORMAT (strict JSON, no markdown, no explanation):
-{
- "problem_type": "",
- "sub_type": "",
- "given": {
- "
""",
- "tokens": 60,
- "requires": []
- },
-
- "CIRCLE_TANGENT": {
- "template": """🎯 נושא: משיק למעגל בנקודה
-שלב 1: בדוק שהנקודה על המעגל.
-שלב 2: מצא שיפוע הרדיוס (מהמרכז לנקודה).
-שלב 3: שיפוע המשיק = -1 / שיפוע הרדיוס (מאונך). כתוב משוואת ישר.""",
- "tokens": 70,
- "requires": []
- },
-
- "TRIANGLE_AREA": {
- "template": """🎯 נושא: שטח משולש
-נוסחה: S = ½ × בסיס × גובה. הצב וחשב.""",
- "tokens": 40,
- "requires": []
- },
-
- "LINE_EQUATION": {
- "template": """🎯 נושא: משוואת ישר
-מצא שיפוע m ונקודת חיתוך b. כתוב: y = mx + b.""",
- "tokens": 40,
- "requires": []
- },
-
- "LINE_PERPENDICULAR": {
- "template": """🎯 נושא: ישר מאונך
-כלל: m₁ × m₂ = -1. מצא שיפוע המאונך, הצב נקודה, כתוב משוואה.""",
- "tokens": 50,
- "requires": []
- },
-
- "DISTANCE_FORMULA": {
- "template": """🎯 נושא: מרחק בין שתי נקודות
-נוסחה: d = √[(x₂-x₁)² + (y₂-y₁)²]. הצב מספרים. פשט.""",
- "tokens": 50,
- "requires": []
- },
-
- # ========== CALCULUS ==========
-
- "DERIVATIVE_POWER": {
- "template": """🎯 נושא: נגזרת — כלל החזקה
-כלל: (xⁿ)' = n·xⁿ⁻¹. גזור כל איבר בנפרד. פשט.""",
- "tokens": 50,
- "requires": []
- },
-
- "DERIVATIVE_QUOTIENT": {
- "template": """🎯 נושא: נגזרת — כלל המנה
-כלל: (u/v)' = (u'v - uv') / v². זהה u ו-v. גזור כל אחד. הצב.""",
- "tokens": 60,
- "requires": []
- },
-
- "DERIVATIVE_PRODUCT": {
- "template": """🎯 נושא: נגזרת — כלל המכפלה
-כלל: (u·v)' = u'v + uv'. זהה u ו-v. גזור כל אחד. הצב.""",
- "tokens": 60,
- "requires": []
- },
-
- "DERIVATIVE_CHAIN": {
- "template": """🎯 נושא: נגזרת — כלל השרשרת
-כלל: [f(g(x))]' = f'(g(x)) · g'(x). זהה פונקציה חיצונית ופנימית. גזור.""",
- "tokens": 70,
- "requires": []
- },
-
- "INVESTIGATION_EXTREMA": {
- "template": """🎯 נושא: חישוב נקודות קיצון
-שלב 1: f'(x) = 0 — מצא נקודות אפשריות.
-שלב 2: בדוק ב-f''(x) — f''>0 → מינימום, f''<0 → מקסימום.
-שלב 3: הצב בחזרה ב-f(x) למציאת ערך y.""",
- "tokens": 90,
- "requires": []
- },
-
- "INVESTIGATION_MONOTONICITY": {
- "template": """🎯 נושא: עליה וירידה של פונקציה
-מצא f'(x). פתור f'(x) = 0. בדוק סימן f'(x) בכל קטע.
-f'>0 → עולה, f'<0 → יורדת.""",
- "tokens": 80,
- "requires": []
- },
-
- # ========== ALGEBRA ==========
-
- "LINEAR_EQUATION": {
- "template": """🎯 נושא: משוואה לינארית
-פתור שלב-אחר-שלב: בודד x בצד אחד.""",
- "tokens": 30,
- "requires": []
- },
-
- "QUADRATIC_EQUATION": {
- "template": """🎯 נושא: משוואה ריבועית
-השתמש בנוסחת פתרון: x = [-b ± √(b²-4ac)] / 2a
-חשב דיסקרימיננטה. מצא שתי תשובות (או שורש כפול).""",
- "tokens": 60,
- "requires": []
- },
-
- "SYSTEM_EQUATIONS": {
- "template": """🎯 נושא: מערכת משוואות
-פתור בשיטת הצבה או החסרה. הצב x חזרה למשוואה לווידוא.""",
- "tokens": 60,
- "requires": []
- },
-}
-
-
-# ==================== PROMPT BUILDER ====================
-
-def get_micro_prompt(topic_id: str, data: dict, grade: str = "10") -> str:
- """
- Returns topic-focused prefix only.
- Safety filter: Middle school (7-9) cannot use CALCULUS topics.
- """
- if topic_id not in MICRO_PROMPTS:
- raise KeyError(f"Topic '{topic_id}' not found in MICRO_PROMPTS")
-
- # Safety Filter for V4.2.11
- is_middle_school = False
- try:
- grade_val = int(re.search(r'\d+', str(grade)).group())
- is_middle_school = 7 <= grade_val <= 9
- except:
- pass
-
- if is_middle_school and ("DERIVATIVE" in topic_id or "INVESTIGATION" in topic_id):
- print(f"🛡️ [Safety Filter] Micro-Prompt Filter: Topic {topic_id} blocked for Grade {grade}. Falling back to GENERAL.")
- return get_general_prompt(data)
-
- config = MICRO_PROMPTS[topic_id]
- template = config["template"]
-
- # No required fields anymore — all templates are self-contained
- focus_block = template.strip()
-
- # Prepend focus to the general prompt (which has the anchor + schema)
- return f"{focus_block}\n\n{get_general_prompt(data)}"
-
-
-def get_prompt_token_count(topic_id: str) -> int:
- """Get estimated token count for topic's micro-prompt"""
- return MICRO_PROMPTS.get(topic_id, {}).get("tokens", 60)
-
-
-def get_general_prompt(data_anchor: dict) -> str:
- """
- Clean context provider.
- Provides the data anchor without conflicting schemas.
- """
- anchor_json = json.dumps(data_anchor, ensure_ascii=False, indent=2)
- return f"""📊 [DATA ANCHOR] Problem context (Absolute Truth):
-{anchor_json}
-
-Instruction: Use the specific data above to solve the problem.
-Do not provide any explanations outside the required JSON structure."""
-
-
-
-# ==================== USAGE EXAMPLE ====================
-
-if __name__ == "__main__":
- test_data = {"equations": ["x^2 - 4 = 0"], "function_equations": ["f(x) = ln(x)/(x^2-4)"]}
- for topic in ["CIRCLE_EQUATION", "DERIVATIVE_QUOTIENT", "LINEAR_EQUATION"]:
- prompt = get_micro_prompt(topic, test_data)
- print(f"=== {topic} ===\n{prompt[:200]}\n")
diff --git a/ocr_strip_engine.py b/ocr_strip_engine.py
deleted file mode 100644
index 9e076fca8f1e2b8fbd318e6de6bc67f45a68532b..0000000000000000000000000000000000000000
--- a/ocr_strip_engine.py
+++ /dev/null
@@ -1,306 +0,0 @@
-# ocr_strip_engine.py - V303.2 (Adaptive Pipeline & Two-Pass Sniper)
-import os
-import io
-import json
-import re
-import logging
-import asyncio
-from pathlib import Path
-import numpy as np
-from PIL import Image
-import cv2
-from utils.safe_json import safe_extract_json # V1.0: Canonical JSON extractor
-
-logger = logging.getLogger(__name__)
-
-DEBUG_DIR = Path("/tmp/debug_ocr")
-DEBUG_DIR.mkdir(parents=True, exist_ok=True)
-
-# --- Constants for block detection ---
-MIN_BLOCK_W = 50
-MIN_BLOCK_H = 10
-LARGE_BLOCK_H = 100
-ROW_MERGE_GAP = 2
-
-def _adaptive_preprocess(image_bytes: bytes) -> np.ndarray:
- """
- V303.2: Adaptive Preprocessing Pipeline
- Decides how aggressively to process based on image size.
- """
- np_img_raw = np.frombuffer(image_bytes, np.uint8)
- img_bgr = cv2.imdecode(np_img_raw, cv2.IMREAD_COLOR)
- file_size_kb = len(image_bytes) / 1024
-
- logger.info(f"📸 [OCR-ADAPTIVE] Input image size: {file_size_kb:.1f} KB")
-
- if file_size_kb < 500:
- # --- LOW-RES MODE (PC Screenshots / Snips) ---
- logger.info("🔧 [OCR-ADAPTIVE] Low-Res mode triggered: Upscaling and applying heavy morphology.")
- # 1. Upscale x2 to save thin pixels
- img_bgr = cv2.resize(img_bgr, None, fx=2.0, fy=2.0, interpolation=cv2.INTER_CUBIC)
- gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
- # 2. Strong CLAHE
- clahe = cv2.createCLAHE(clipLimit=2.5, tileGridSize=(8,8))
- cl1 = clahe.apply(gray)
- # 3. Morph Close (thicken lines)
- kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2))
- processed = cv2.morphologyEx(cl1, cv2.MORPH_CLOSE, kernel)
- else:
- # --- HIGH-RES MODE (Phone Camera in Production) ---
- logger.info("📱 [OCR-ADAPTIVE] High-Res mode triggered: Mild enhancement only.")
- gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
-
- # V1.1: Pre-normalization (Contrast/Brightness Balance)
- alpha = 1.2 # Contrast
- beta = 10 # Brightness
- gray = cv2.convertScaleAbs(gray, alpha=alpha, beta=beta)
-
- # Mild CLAHE just to balance lighting, NO morphological distortion
- clahe = cv2.createCLAHE(clipLimit=1.5, tileGridSize=(8,8))
- processed = clahe.apply(gray)
-
- return cv2.cvtColor(processed, cv2.COLOR_GRAY2BGR)
-
-
-def _find_raw_blocks(np_bgr: np.ndarray) -> list[tuple]:
- gray = cv2.cvtColor(np_bgr, cv2.COLOR_BGR2GRAY)
- blur = cv2.GaussianBlur(gray, (7, 7), 0)
- thresh = cv2.adaptiveThreshold(blur, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2)
- # Kernel optimized for both modes
- kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (60, 3))
- dilate = cv2.dilate(thresh, kernel, iterations=1)
- contours, _ = cv2.findContours(dilate, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
- blocks = []
- for c in contours:
- x, y, w, h = cv2.boundingRect(c)
- if w >= MIN_BLOCK_W and h >= MIN_BLOCK_H:
- blocks.append((x, y, w, h))
- blocks.sort(key=lambda b: b[1])
- return blocks
-
-def _filter_nested(blocks: list[tuple]) -> list[tuple]:
- filtered = []
- for i, (x1, y1, w1, h1) in enumerate(blocks):
- r1, b1 = x1 + w1, y1 + h1
- nested = False
- for j, (x2, y2, w2, h2) in enumerate(blocks):
- if i == j: continue
- r2, b2 = x2 + w2, y2 + h2
- if x1 >= x2 and y1 >= y2 and r1 <= r2 and b1 <= b2:
- nested = True
- break
- if not nested: filtered.append((x1, y1, w1, h1))
- return filtered
-
-def _merge_same_row(blocks: list[tuple]) -> list[tuple]:
- if not blocks: return []
- merged = []
- cur_x, cur_y, cur_w, cur_h = blocks[0]
- for (x, y, w, h) in blocks[1:]:
- cur_b = cur_y + cur_h
- if cur_h >= LARGE_BLOCK_H or h >= LARGE_BLOCK_H:
- merged.append((cur_x, cur_y, cur_w, cur_h))
- cur_x, cur_y, cur_w, cur_h = x, y, w, h
- continue
- if y <= cur_b + ROW_MERGE_GAP:
- union_x, union_y = min(cur_x, x), min(cur_y, y)
- union_r, union_b = max(cur_x + cur_w, x + w), max(cur_y + cur_h, y + h)
- cur_x, cur_y, cur_w, cur_h = union_x, union_y, union_r - union_x, union_b - union_y
- else:
- merged.append((cur_x, cur_y, cur_w, cur_h))
- cur_x, cur_y, cur_w, cur_h = x, y, w, h
- merged.append((cur_x, cur_y, cur_w, cur_h))
- return merged
-
-def _extract_blocks(np_bgr: np.ndarray) -> list[tuple]:
- raw = _find_raw_blocks(np_bgr)
- dedup = _filter_nested(raw)
- return _merge_same_row(dedup)
-
-def get_best_sniper_roi(img):
- """
- V1.1: Math Structural Heatmap Prior.
- תעדוף אזורים עם צפיפות סמלים מתמטיים גבוהה.
- """
- gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
- _, thresh = cv2.threshold(gray, 150, 255, cv2.THRESH_BINARY_INV)
- contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
-
- ROI_HOMOGRAPHY_THRESHOLD = 50000 # Threshold for local correction (w*h)
- candidates = []
-
- for cnt in contours:
- x, y, w, h = cv2.boundingRect(cnt)
- if w < 20 or h < 10: continue
-
- # V1.1: Symbol Density Check (Heatmap Prior)
- roi_thresh = thresh[y:y+h, x:x+w]
- pixel_count = np.sum(roi_thresh == 255)
- density = pixel_count / (w * h)
-
- # Feature Clustering (Heatmap Weighting)
- # Higher score for small but high-density clusters (usually math symbols)
- heatmap_prior = density * (1.5 if density > 0.15 else 1.0)
-
- # Position Weighting (Top-heavy bias)
- position_weight = (1.0 / (1.0 + 0.005 * y))
-
- confidence_score = heatmap_prior * position_weight
-
- candidates.append({
- 'confidence': confidence_score,
- 'box': (x, y, w, h),
- 'needs_local_homography': (w * h) > ROI_HOMOGRAPHY_THRESHOLD
- })
-
- if not candidates:
- logger.warning("⚠️ No valid candidates found. Fallback.")
- return img[:350, :], 0.0
-
- best = max(candidates, key=lambda c: c['confidence'])
- x, y, w, h = best['box']
-
- # V8.6.4: ROI Hardening — Safe Margins (Padding 50px)
- # Prevent cutting off minus signs from exponents (e.g., e^-x becoming e^x)
- SAFE_PADDING = 50
- y_start = max(0, y - SAFE_PADDING)
- y_end = min(img.shape[0], y + h + SAFE_PADDING)
-
- logger.info(
- f"📐 [OCR-BBOX] Sniper ROI (V8.6.4) — x={x}, y={y}, w={w}, h={h} | "
- f"img=({img.shape[1]}x{img.shape[0]}) | "
- f"crop=[{y_start}:{y_end}, :] | "
- f"confidence={best['confidence']:.3f}"
- )
-
- # Conditional Local Homography Warning (Bit-log only for now)
- if best['needs_local_homography']:
- logger.info(f"📐 [V1.1] ROI ({w}x{h}) exceeds threshold. Local adjustment recommended.")
-
- return img[y_start:y_end, :], best['confidence']
-
-def apply_conditional_homography(roi_img: np.ndarray) -> np.ndarray:
- """
- V1.1: Local Homography Warning.
- Only applies alignment if the ROI is large enough to warrant it.
- Small ROIs stay original to prevent distortion.
- """
- h, w = roi_img.shape[:2]
- area = h * w
-
- if area < 5000: # Small ROI - keep original (Prevent distortion)
- logger.info(f"📐 [V1.1] ROI too small ({area}) - skipping local homography.")
- return roi_img
-
- # Placeholder for actual homography warp
- # In production, this would involve findHomography + warpPerspective
- logger.info(f"📐 [V1.1] ROI large enough ({area}) - ready for local perspective correction.")
- return roi_img
-
-async def transcribe(image_bytes: bytes, vision_model, debug_mode: bool = False) -> tuple[list[dict], float]:
- logger.info("🪡 [OCR-STRIP] V303.6 Production Lock Pipeline Starting...")
-
- # 1. Adaptive Preprocessing
- np_enhanced_bgr = _adaptive_preprocess(image_bytes)
- img_h, img_w = np_enhanced_bgr.shape[:2]
-
- # 2. Get Sniper ROI (V1.1)
- sniper_bgr, roi_confidence = get_best_sniper_roi(np_enhanced_bgr)
-
- # 2b. Apply Local Homography if needed (V1.1)
- # Note: Full homography implementation requires feature matching,
- # for now we implement the threshold logic.
- sniper_bgr = apply_conditional_homography(sniper_bgr)
-
- sniper_image = Image.fromarray(cv2.cvtColor(sniper_bgr, cv2.COLOR_BGR2RGB))
-
- # 3. Reader Pass (Rest of the image)
- # Note: For V303.6 unified/single pass, we still use the full image for the reader or keep it split if preferred.
- # The user instruction implies a "Single Pass" logic for Strategy, but OCR can stay multi-pass as long as it's targeted.
- # We'll stick to a high-quality reader image of the original.
- pil_enhanced = Image.fromarray(cv2.cvtColor(np_enhanced_bgr, cv2.COLOR_BGR2RGB))
- reader_image = pil_enhanced # Full image for context
-
- if debug_mode:
- sniper_image.save(DEBUG_DIR / "ocr_pass1_sniper.jpg")
- reader_image.save(DEBUG_DIR / "ocr_pass2_reader.jpg")
-
- # 4. The Prompts
- sniper_prompt = (
- "Extract ONLY the main mathematical function defined in this image. "
- "It is usually preceded by words like 'נתונה הפונקציה'. "
- "CRITICAL: If any exponent or fraction bar appears small or ambiguous, zoom mentally and transcribe it explicitly using ^ notation. "
- "RETURN ONLY A JSON ARRAY: [{\"type\": \"math\", \"content\": \"...\"}]"
- )
-
- reader_prompt = (
- "Extract all Hebrew text and secondary mathematical content from this image. "
- "RETURN ONLY A JSON ARRAY: [{\"type\": \"text\"|\"math\", \"content\": \"...\"}]"
- )
-
- # 5. Concurrent Processing
- try:
- from google.generativeai.types import GenerationConfig
- gen_config = GenerationConfig(temperature=0.0, top_p=0.1, top_k=1)
- pass1_task = vision_model.generate_content_async([sniper_prompt, sniper_image], generation_config=gen_config)
- pass2_task = vision_model.generate_content_async([reader_prompt, reader_image], generation_config=gen_config)
-
- pass1_response, pass2_response = await asyncio.gather(pass1_task, pass2_task)
-
- blocks_pass1 = _parse_structured_json(pass1_response.text)
- blocks_pass2 = _parse_structured_json(pass2_response.text)
-
- # Merge, prioritizing pass 1 for the function definition
- final_blocks = blocks_pass1 + [b for b in blocks_pass2 if b not in blocks_pass1]
-
- # V303.7: Apply final OCR hotfixes
- for block in final_blocks:
- if block.get("type") == "text":
- block["content"] = finalize_ocr_text(block["content"])
-
- logger.info(f"✅ V303.6 Complete. Sniper: {len(blocks_pass1)}, Reader: {len(blocks_pass2)} (Confidence: {roi_confidence:.2f})")
- return final_blocks, roi_confidence
-
- except Exception as e:
- logger.exception("CRITICAL FLOW ERROR")
- logger.error(f"❌ OCR V303.6 FAILED: {e}")
- return [{"type": "text", "content": "שגיאת תקשורת בפענוח."}], 0.0
-
-def finalize_ocr_text(text: str) -> str:
- """V1.1.2: Corrects common OCR misinterpretations in Hebrew context."""
- if not text: return ""
- text = text.replace("ציר ע", "ציר y")
- text = text.replace("ציר E", "ציר y") # Common misinterpretation (E looks like y in some fonts)
- text = text.replace("ציר ץ", "ציר y") # Another common one
- return text
-
-def _parse_structured_json(raw_text: str) -> list[dict]:
- """V1.0: Uses canonical safe_extract_json (logs RAW, fail-closed)."""
- result = safe_extract_json(raw_text, caller="OCR", allow_array=True)
- if isinstance(result, list):
- # Flatten nested lists (LLM sometimes wraps array in array)
- flat = []
- for item in result:
- if isinstance(item, list):
- flat.extend(item)
- elif isinstance(item, dict):
- flat.append(item)
- elif isinstance(item, str) and item.strip():
- # V9.0.2 FIX: Handle strings by wrapping them in a text block
- flat.append({"type": "text", "content": item.strip()})
- return [p for p in flat if isinstance(p, dict)]
- if isinstance(result, dict) and not result.get("logic_error"):
- return [result]
- logger.error(f"[OCR] _parse_structured_json: parse failed for: {raw_text[:200]!r}")
- return []
-
-
-def paginate_image(image_bytes, debug_mode=False):
- return [Image.open(io.BytesIO(image_bytes)).convert("RGB")]
-
-def flatten_to_text(structured: list[dict]) -> str:
- parts = []
- for item in structured:
- if item.get("type") == "math": parts.append(f"${item.get('content', '')}$")
- else: parts.append(item.get("content", ""))
- return " ".join(parts)
\ No newline at end of file
diff --git a/orchestrator.py b/orchestrator.py
deleted file mode 100644
index a6a721f7ba3a4fb3f6fa00168df1427ba3e73f23..0000000000000000000000000000000000000000
--- a/orchestrator.py
+++ /dev/null
@@ -1,3110 +0,0 @@
-# buddy_math_server/orchestrator.py - V273.0 (SMART CLASSIFICATION + FAST PATH)
-import json, re, os, prompts, asyncio, time
-from typing import List, Dict, Optional, Any
-import sympy as sp
-import logging, re
-import ocr_strip_engine # V300: Stitch & Strip OCR engine
-from utils.safe_json import safe_extract_json # V1.0: Canonical JSON extractor
-from domain.math_validator import MathPolygraph # V1.0: SymPy Polygraph
-from geometric_sanity import run_geometric_sanity # V1.0: Geometric Sanity Engine
-from domain.processing_strategy import ProcessingStrategy
-from domain.ontology import get_allowed_concepts, get_pedagogical_tag
-from domain.math_normalizer import MathCanonicalizer
-from utils.math_utils import sanitize_latex_for_sympy, aggressive_sympy_sanitizer
-from domain.curriculum_classifier import CurriculumClassifier
-from domain.proposal_engine import ProposalEngine
-from domain.risk_engine import CognitiveRiskEngine
-from domain.pedagogical_renderer import PedagogicalRenderer
-from domain.validator import ConsistencyGate
-from smart_solver import sign_step, resolve_ast_target, execute_action
-import domain.telemetry as telemetry
-from domain.schemas import BuddyEvent, BuddyState # V8.5: Streaming contract
-from firebase_manager import firebase_manager
-from config import IS_PRODUCTION, ENV, GEMINI_MODEL, CONFIDENCE_THRESHOLD_HIGH, CONFIDENCE_THRESHOLD_MEDIUM
-import google.generativeai as genai
-from pydantic import BaseModel, Field
-
-# V318.0: Tutor Response Schema for Structured JSON Output
-class TutorInternalAnalytics(BaseModel):
- topic: str = Field(description="The mathematical topic being discussed")
- intent: str = Field(description="The student's intent: 'SOLVE', 'CHECK', or 'CHAT'")
- mastery_score: int = Field(description="Estimated mastery score (0-100) based on this interaction")
- error_analysis: Optional[str] = Field(description="Brief analysis of any errors found")
-
-class TutorResponseSchema(BaseModel):
- student_message: str = Field(description="The encouraging pedagogical response for the student")
- internal_analytics: TutorInternalAnalytics = Field(description="Metadata for system analysis")
-
-# V8.6.9: Global Guardrails (Increased for High-Complexity 5-Unit Problems - V317.8)
-GLOBAL_TOKEN_LIMIT = 100000
-GLOBAL_TIMEOUT_SEC = 300
-
-# ==================== V7.2: TICKET 1 — AST ENRICHMENT HELPERS ====================
-
-
-def collect_all_steps(data: dict) -> list:
- """
- V1.0 Polygraph Helper: Deep extraction of all step objects from a response dict.
- Future-proofed to handle nested sub_sections and explanation_steps.
- """
- steps = []
- print(f"🔍 [DEBUG] data type: {type(data)}")
- if not isinstance(data, dict):
- print(f"⚠️ [V1.0] collect_all_steps: Expected dict, got {type(data)}")
- return []
- for section in data.get("sections", []):
- steps.extend(section.get("steps", []))
- # Future-proofing: handle nested structures
- for sub in section.get("sub_sections", []):
- steps.extend(sub.get("steps", []))
- steps.extend(section.get("explanation_steps", []))
- return steps
-
-
-def build_ast_metadata(math_input: str, category: str) -> dict:
- """
- V7.2 Ticket 1: Builds a rich AST metadata object to pass to LLM #1 (Planner).
- The Planner never receives the raw math string — only this structured metadata.
- """
- variables = []
- constraints = [] # V7.3: domain constraints (placeholder)
- detected_operations = [str(category)]
- estimated_complexity = 0.5
-
- try:
- parts = aggressive_sympy_sanitizer(math_input)
- free_syms = set()
- for clean_part in parts:
- try:
- expr = sp.sympify(clean_part.replace('=', '-'), evaluate=False)
- free_syms.update(expr.free_symbols)
- except Exception as e:
- logging.debug(f"[AST_METADATA] SymPy parse failed for part: {e}")
- pass
-
- variables = sorted([str(s) for s in free_syms])
- raw_complexity = CurriculumClassifier.estimate_complexity(math_input)
- estimated_complexity = round(raw_complexity / 10.0, 2)
-
- except Exception as e:
- logging.warning(f"[AST_METADATA] Failed to enrich metadata: {e}")
-
- # Build node registry for Planner (IDs → expressions)
- ast_registry = {}
- try:
- parts = aggressive_sympy_sanitizer(math_input)
- for i, part in enumerate(parts):
- ast_registry[f"ast_node_{i}"] = part
- except Exception as e:
- logging.debug(f"[AST_METADATA] Registry build failed: {e}")
- ast_registry["ast_node_0"] = str(math_input)
-
- return {
- "variables": variables,
- "constraints": constraints,
- "detected_operations": detected_operations,
- "estimated_complexity": estimated_complexity,
- "ast_registry": ast_registry # Shared secretly with solver; NOT sent to LLM
- }
-
-
-def _abstract_visual_context(data_anchor: dict) -> dict:
- """
- V7.2 Ticket 1: Strips raw OCR/visual payload and converts to abstract metadata.
- The Planner NEVER receives raw image data.
- """
- if not data_anchor:
- return {}
-
- # Preserve only safe, abstract fields
- abstract = {}
-
- graph_relations = []
- if "graphs" in data_anchor:
- for i, g in enumerate(data_anchor["graphs"]):
- graph_relations.append({
- "graph": chr(ord("I") + i), # I, II, III...
- "zeros": g.get("zeros", 0),
- "type": g.get("type", "unknown")
- })
-
- if graph_relations:
- abstract["graph_relations"] = graph_relations
-
- return abstract
-
-
-# ==================== V7.2: TICKET 5 — UI GATE WHITELIST SCAN ====================
-
-def scan_for_math_leakage(rendered_text: str) -> bool:
- """
- V7.2 Ticket 5: Full Whitelist scan (NOT a blacklist).
- After removing {{...}} placeholders, the remaining text may ONLY contain:
- - Hebrew letters (Unicode block)
- - English letters (for regular words)
- - Spaces and basic punctuation (. , ? ! ' " - :)
-
- Any digit-letter combo, math symbols, trig functions, or equals signs → REJECT.
- """
- # Remove all valid placeholders first
- clean_text = re.sub(r'\{\{\w+\}\}', '', rendered_text).strip()
-
- if not clean_text:
- return True # Text was purely placeholders — safe
-
- ALLOWED_PATTERN = r'^[\u0590-\u05FFa-zA-Z\s.,;:!?\-\"\']+$'
-
- if re.match(ALLOWED_PATTERN, clean_text):
- return True
-
- # Log the exact leaking characters for forensics
- violations = re.sub(r'[\u0590-\u05FFa-zA-Z\s.,;:!?\-\"\']+', '', clean_text)
- logging.warning(f"[UI_GATE] Math leakage detected! Offending chars: '{violations[:50]}'")
- return False
-
-
-
-def extract_and_parse_json(text: str):
- """V5.7.5 → V1.0: Delegates to canonical safe_extract_json."""
- return safe_extract_json(text, caller="ORCHESTRATOR_LEGACY")
-
-
-def validate_and_sanitize_response(resp_json, category="GENERAL"):
- """V4.2.16: Validator מדויק - חוסם קוד, מאפשר גיאומטריה (ABC)"""
- has_error = False
- forbidden_terms = ["נגזרת", "גזירה", "אסימפטוטה", "נקודת קיצון"]
-
- print(f"🛡️ [BIT-LOG: VALIDATOR] Checking section in category: {category}")
-
- if "sections" in resp_json:
- for section in resp_json["sections"]:
- for step in section.get("steps", []):
- text = step.get("explanation_text", "")
- # 1. חסימת חדו"א באלגברה (Normalize category for Case Sensitivity Fix)
- if category.upper() != "INVESTIGATION" and any(t in text for t in forbidden_terms):
- print(f"🚨 [BIT-LOG: VALIDATOR] Calculus leak detected!")
- has_error = True
- # 2. חסימת פונקציות/קוד (f(x) , import) אבל השארת ABC (Relaxed for False Positives)
- if re.search(r'\b(import|def|class|lambda)\b', text):
- print(f"🚨 [BIT-LOG: VALIDATOR] Code/Math leak in text: '{text[:20]}'")
- has_error = True
- step["explanation_text"] = "הסבר לא זמין עקב חריגה מהחוזה הפדגוגי."
-
- resp_json["logic_error"] = resp_json.get("logic_error", False) or has_error
-
- # V317.5: UI Sanitization Layer
- if not resp_json.get("logic_error"):
- resp_json = sanitize_llm_output(resp_json)
-
- return resp_json
-
-def unify_data_anchor(raw_data):
- """V317.5: Smart Data Anchor Unification (Prevents key overwrite)"""
- if isinstance(raw_data, dict):
- return raw_data
-
- if isinstance(raw_data, list):
- unified = {}
- for item in raw_data:
- if not isinstance(item, dict): continue
- for key, value in item.items():
- if key in unified:
- # אם המפתח כבר קיים, נהפוך אותו לרשימה ונוסיף אליו
- if isinstance(unified[key], list):
- if value not in unified[key]:
- unified[key].append(value)
- else:
- if unified[key] != value:
- unified[key] = [unified[key], value]
- else:
- unified[key] = value
- return unified
-
- return {} # Fallback
-
-def sanitize_llm_output(json_response):
- """V317.5: Cleans technical errors (SYMPY_PARSE_ERROR) and Hebrew from LaTeX."""
- if not isinstance(json_response, dict):
- return json_response
-
- if "steps" in json_response:
- for step in json_response["steps"]:
- block_math = step.get("block_math", "")
- if block_math:
- # Mission 2: זיהוי שגיאות של SymPy
- if "SYMPY_PARSE_ERROR" in block_math:
- step["block_math"] = ""
- step["content_mixed"] = step.get("content_mixed", "") + "\n(המשוואה הוסתרה עקב קושי בתצוגה)."
-
- # Mission 2: זיהוי אותיות בעברית בתוך ה-LaTeX
- elif re.search(r'[א-ת]', block_math):
- # מעבירים את התוכן לשדה הטקסט ומוחקים את הבלוק המתמטי
- clean_math = block_math.replace('\\text{', '').replace('}', '').replace('$', '')
- step["content_mixed"] = step.get("content_mixed", "") + f"\n[{clean_math}]"
- step["block_math"] = ""
-
- # V280.0: Also check final_answer
- if "final_answer" in json_response and "SYMPY_PARSE_ERROR" in str(json_response["final_answer"]):
- json_response["final_answer"] = "התקבלה תשובה מורכבת (ראה שלבים מלאים)."
-
- return json_response
-
-import asyncio
-
-async def safe_llm_call(generator_func, timeout_seconds=45.0):
- try:
- # הגבלת זמן ריצה למניעת תקיעות שרת במקרה של רשת איטית
- raw_output = await asyncio.wait_for(generator_func(), timeout=timeout_seconds)
-
- # Handle if the function already returned parsed dict/list
- if isinstance(raw_output, (dict, list)):
- return raw_output
-
- if hasattr(raw_output, 'text'):
- raw_output = raw_output.text
-
- # V1.0: Use canonical safe_extract_json (logs RAW, fail-closed)
- result = safe_extract_json(raw_output, caller="SAFE_LLM_CALL")
- if isinstance(result, dict) and result.get("logic_error"):
- return build_standard_response(
- logic_error=True,
- error_type="STREAM_OR_CONTRACT_FAILURE",
- final_answer="התשובה לא התקבלה בצורה מלאה. נסו לסרוק שוב 📸",
- sections=[]
- )
- return result
-
- except Exception as e:
- logger.error(f"🚨 [FINAL_SHIELD] Exception caught: {str(e)}")
- # חזרה בטוחה למבנה שגיאה תקני ל-Flutter
- return build_standard_response(
- logic_error=True,
- error_type="STREAM_OR_CONTRACT_FAILURE",
- final_answer="התשובה לא התקבלה בצורה מלאה. נסו לסרוק שוב 📸",
- sections=[]
- )
-
-def enforce_step_contract(proof_steps: list, llm_output: list):
- # 1. בדיקת כמות צעדים (חובה התאמה מלאה)
- if len(proof_steps) != len(llm_output):
- return False, "PEDAGOGICAL_STEP_MISMATCH"
-
- # 2. אימות זהות השלבים (Step ID Binding)
- for step_rule, step_llm in zip(proof_steps, llm_output):
- if step_rule.get("step_id") != step_llm.get("step_id"):
- return False, "STEP_ID_VIOLATION"
-
- return True, None
-
-def build_standard_response(
- sections=None,
- final_answer="",
- teacher_summary="סיימנו את פתרון התרגיל.",
- graph_base64=None,
- audio_base64=None,
- logic_error=False,
- response_type="standard",
- strategy_card=None,
- visual_context=None,
- error_type=None
-):
- """
- Standardize the output format for all responses.
- """
- # 🧹 Firewall 3: Sterilization
- if logic_error:
- # If there's an error, final_answer MUST just be the error message.
- # We strip away any sections to prevent hallucinated data from reaching the UI.
- sections = []
-
- response = {
- "final_answer": final_answer,
- "teacher_summary": teacher_summary,
- "sections": sections or [],
- "graph_base64": graph_base64,
- "audio_base64": audio_base64,
- "logic_error": logic_error,
- "type": response_type,
- "strategy_card": strategy_card,
- "visual_context": visual_context
- }
-
- if error_type:
- response["error_type"] = error_type
- logger.info(f"🏗️ [TRACE] FINAL JSON OUT: {response}") # Changed final_response to response
- return response
-
-def build_structured_projection(llm_commentaries, sympy_steps):
- """ממזג הסברים מילוליים עם הלוח המתמטי ללא מגע יד אדם (V4.2.7)"""
- structured_response = []
- # Ensure we don't exceed the number of available commentaries
- for i, step in enumerate(sympy_steps):
- commentary = llm_commentaries[i] if i < len(llm_commentaries) else "נבצע את השלב הבא."
-
- # Determine artifact type (basic heuristic for now)
- artifact_type = "equation"
- if "table" in str(step.math_content).lower() or "|" in str(step.math_content):
- artifact_type = "table"
-
- structured_response.append({
- "step_id": i + 1,
- "step_number": i + 1, # Backward compatibility
- "explanation_text": commentary, # הדיבור של המורה
- "content_mixed": commentary, # Backward compatibility
- "math_artifact": {
- "type": artifact_type,
- "latex": step.math_content,
- "table_data": "" # For future expansion
- },
- "block_math": step.math_content # Backward compatibility
- })
- return structured_response
-
-logger = logging.getLogger(__name__)
-
-def select_best_anchor(candidates: list[str]) -> str:
- """בחירת הגרסה הארוכה ביותר (מניח שלמות מתמטית)"""
- if not candidates: return ""
- return max(candidates, key=len)
-
-def normalize_latex_for_sympy(expr: str) -> str:
- # המרת נגזרות לפורמט ש-SymPy מבין בצורה דינמית
- expr = re.sub(r"([a-zA-Z])'\((.*?)\)", r"Derivative(\1(\2), x)", expr)
- expr = expr.replace("\\", "")
- return expr
-
-def verify_math_consistency(anchor_latex: str, final_result_latex: str):
- """
- V4.7 Hardened: Handles equations with '=' and never fails silently.
- """
- try:
- def clean_and_parse(latex_str):
- # ניקוי בסיסי והמרת נגזרות
- clean_str = normalize_latex_for_sympy(latex_str).replace('{', '(').replace('}', ')')
- # טיפול במשוואות: העברת אגפים
- parts = clean_str.split('=')
- if len(parts) == 2:
- return sp.sympify(parts[0]) - sp.sympify(parts[1])
- return sp.sympify(parts[0])
-
- a = clean_and_parse(anchor_latex)
- b = clean_and_parse(final_result_latex)
-
- is_identical = bool(sp.simplify(sp.expand(a - b)) == 0)
- return is_identical, (1.0 if is_identical else 0.6)
-
- except Exception as e:
- logger.error(f"❌ Verification crashed on input: {e}")
- # BREAKING FIX: חובה להחזיר False במקרה של קריסה!
- return False, 0.5
-from dotenv import load_dotenv
-load_dotenv() # Load .env BEFORE genai.configure()
-import google.generativeai as genai
-from smart_solver import SmartSolver
-from gibberish_detector import validate_and_fix_solution
-import gibberish_detector # For fix_gibberish_smart
-import visuals
-
-# V231.12: Import smart architecture modules
-from strategy_manager import StrategyManager
-from pedagogical_builder import build_pedagogical_response, sanitize_math_text
-import cost_tracker # V231.26: Log usage
-from audio_generator import generate_teacher_audio # V261.5: Teacher TTS
-
-# V1.1: Math Safety Lock Modules
-import math_intent_detector
-import curriculum_engine
-import strategy_policy_engine
-from proof_graph import ProofGraph, ProofStep, validate_pedagogical_legality
-from math_sanitizer import ProductionMathSanitizer
-from pedagogical_builder import build_pedagogical_response, sanitize_math_text, merge_and_verify_explanations, LLMSchemaError
-
-# V4.0: Curriculum Oracle
-# curriculum_engine is already imported above, no need to re-import
-# import curriculum_engine
-
-# V231.14: Import problem understanding
-import problem_understanding
-
-try: from json_repair import repair_json
-except: repair_json = lambda x: x
-
-def safe_json_loads(raw_text: str) -> dict:
- """V1.0: Delegates to canonical safe_extract_json with LaTeX shield."""
- return safe_extract_json(raw_text, caller="ORCHESTRATOR_SAFE_LOADS")
-
-
-from dataclasses import dataclass
-from smart_solver import ActionContext
-
-@dataclass
-class PipelineContext:
- grade: str
- grade_num: int
- topic: str
- math_input: str
- confidence: float
- category: str = "GENERAL"
- original_text: str = "" # V4.2.15: For intent-based gating
- sub_question_text: str = "" # V7.3: Per-question routing context
-
-class BuddyOrchestrator:
- def handle_fallback(self, context: PipelineContext):
- """V4.2.3: Safe fallback for solver failures."""
- print(f"🔄 [FALLBACK] Handling solver failure for grade {context.grade_num}")
- from types import SimpleNamespace
- return SimpleNamespace(success=False)
- def __init__(self):
- print("✅ 🟢 [BIT-LOG: המורה למתמטיקה V273.0] - SMART CLASSIFICATION + FAST PATH")
-
- genai.configure(api_key=os.environ.get("GOOGLE_API_KEY", ""))
- # V8.6.1: Force Strict JSON Output to prevent Markdown/Preamble leakage
- self.model = genai.GenerativeModel(
- model_name=GEMINI_MODEL,
- generation_config={"response_mime_type": "application/json"}
- )
- self.vision_model = genai.GenerativeModel(
- model_name=GEMINI_MODEL,
- generation_config={"response_mime_type": "application/json"}
- )
- self.smart_solver = SmartSolver() # No model parameter needed
-
- # V231.12: Initialize strategy manager
- self.strategy_manager = StrategyManager(self.model)
- self._last_ocr_confidence = 1.0 # Default confidence (V3.1.2)
- print("🎯 [BIT-LOG] StrategyManager initialized")
-
- # ===================== V273.0: SMART QUESTION CLASSIFICATION =====================
-
- def _quick_classify(self, problem_text: str) -> ProcessingStrategy:
- """
- V5.8.0: Deterministic classification returning strict ProcessingStrategy
- """
- # ---------- MULTI PART / COMPLEX STRUCTURE ----------
- if re.search(r'[אבגדהו][\.\)\:\s]', problem_text) or re.search(r'סעיף\s*[אבגדהו]', problem_text):
- return ProcessingStrategy.STRICT_SYMBOLIC
-
- complex_keywords = [
- 'חקור', 'חקירת', 'הוכח', 'הוכיח',
- 'מקום גיאומטרי', 'הראה כי', 'הראי כי',
- 'נתונה פונקציה', 'נתון משולש'
- ]
- if any(kw in problem_text for kw in complex_keywords):
- return ProcessingStrategy.STRICT_SYMBOLIC
-
- # ---------- PURE MATH EXPRESSION ----------
- math_only = re.sub(r'[\u0590-\u05FF\s]', '', problem_text)
- if len(problem_text) < 80 and len(math_only) > len(problem_text) * 0.4:
- return ProcessingStrategy.SIMPLE_ARITHMETIC
-
- # ---------- SHORT CALCULATION ----------
- simple_keywords = [
- 'חשב', 'חשבי', 'פשט', 'פשטי',
- 'מהו', 'מהי', 'כמה',
- 'מצא', 'מצאי', 'פתור', 'פתרי'
- ]
-
- if len(problem_text) < 150 and any(kw in problem_text for kw in simple_keywords):
- if not any(kw in problem_text for kw in ['ולכן', 'לפיכך', 'הסבר', 'נמק']):
- return ProcessingStrategy.SIMPLE_ARITHMETIC
-
- # ---------- DEFAULT ----------
- return ProcessingStrategy.HEURISTIC_DEDUCTION
-
- async def _llm_classify(self, problem_text: str) -> dict:
- """
- V273.0: סיווג עם LLM - למקרים לא ברורים
- """
- prompt = f"""
-סווג את השאלה המתמטית הבאה. החזר JSON בלבד.
-
-שאלה:
-"{problem_text[:500]}"
-
-קטגוריות:
-- SIMPLE = חישוב בודד, תשובה אחת, בלי סעיפים (דוגמה: "חשב 3+5", "פשט את הביטוי x²-4")
-- MULTI_PART = יש סעיפים א,ב,ג או מספר שאלות נפרדות
-- COMPLEX = חקירת פונקציה, הוכחה, גיאומטריה מורכבת, בעיה עם כמה שלבים
-
-JSON:
-{{
- "complexity": "SIMPLE" / "MULTI_PART" / "COMPLEX",
- "num_parts": מספר (1 אם פשוט, 2-6 אם יש סעיפים),
- "reason": "הסבר קצר מאוד"
-}}
-"""
-
- try:
- res = await asyncio.wait_for(
- self.model.generate_content_async(
- prompt,
- generation_config={"temperature": 0.0}
- ),
- timeout=8.0
- )
- cost_tracker.log_api_usage(res.usage_metadata, "CLASSIFY_QUESTION")
-
- match = re.search(r'\{.*\}', res.text, re.DOTALL)
- if match:
- data = safe_json_loads(match.group())
- data["confidence"] = "HIGH"
- data["source"] = "LLM"
- print(f"🏷️ [CLASSIFY] LLM result: {data}")
- return data
- except Exception as e:
- print(f"⚠️ [CLASSIFY] LLM failed: {e}")
-
- # Fallback - assume complex to be safe
- return {"complexity": "COMPLEX", "num_parts": 1, "confidence": "LOW", "source": "FALLBACK"}
-
- async def _classify_question(self, problem_text: str) -> dict:
- """
- V273.0: סיווג משולב - מהיר קודם, LLM רק אם צריך
- """
- print(f"🏷️ [CLASSIFY] Analyzing: {problem_text[:60]}...")
-
- # Step 1: Quick classification (no LLM)
- quick_result = self._quick_classify(problem_text)
-
- if quick_result["confidence"] == "HIGH":
- print(f"🏷️ [CLASSIFY] Quick result: {quick_result['complexity']} ({quick_result['source']})")
- return quick_result
-
- if quick_result["confidence"] == "MEDIUM":
- # Medium confidence - use it but log
- print(f"🏷️ [CLASSIFY] Medium confidence: {quick_result['complexity']} ({quick_result['source']})")
- return quick_result
-
- # Step 2: Need LLM classification
- print(f"🏷️ [CLASSIFY] Needs LLM classification...")
- return await self._llm_classify(problem_text)
-
- # ===================== V273.0: FAST PATH FOR SIMPLE QUESTIONS =====================
-
- async def _quick_solve(
- self,
- problem_text: str,
- grade: str,
- student_name: str,
- image_data: bytes = None,
- ambiguity_warning: bool = False
- ) -> dict:
- """
- V273.0: פתרון מהיר לשאלות פשוטות - קריאה אחת ל-LLM
- """
- print(f"⚡ [FAST PATH] Solving simple question...")
-
- prompt = f"""
-אתה "המורה למתמטיקה" - מורה פרטית חמה ומקצועית.
-
-פתור את השאלה הבאה עבור {student_name} (כיתה {grade}):
-
-"{problem_text}"
-
-הנחיות קריטיות:
-1. פתור צעד אחר צעד בעזרת האובייקט `ctx` הקיים בלבד.
-2. **אסור בשום פנים ואופן לכתוב הגדרות של מחלקות (class), פונקציות (def) או יבואים (import).**
-3. השתמש ב- `ctx.explain("הסבר")` לכל שלב מילולי.
-4. השתמש ב- `ctx.declare_equation("תיאור", ctx.Eq(x, 5))` למשוואות.
-5. סיים ב- `ctx.finish("תשובה סופית ב-LaTeX", "סיכום מורה")`.
-6. החזר אך ורק בלוק קוד פייתון נקי בתוך ```python.
-
-דוגמה למבנה הקוד הרצוי:
-```python
-ctx.explain("ראשית נחבר את המספרים.")
-ctx.declare_equation("פעולת החיבור", ctx.Eq(2 + 2, 4))
-ctx.finish("$$ 4 $$", "מעולה! הגענו לתוצאה.")
-```
-"""
-
- try:
- import asyncio
- if image_data:
- # Use vision model
- res = await asyncio.wait_for(
- asyncio.to_thread(
- self.vision_model.generate_content,
- [
- prompt,
- {"mime_type": "image/png", "data": image_data}
- ]
- ),
- timeout=30.0
- )
- else:
- res = await asyncio.wait_for(
- asyncio.to_thread(self.model.generate_content, prompt),
- timeout=30.0
- )
-
- cost_tracker.log_api_usage(res.usage_metadata, "FAST_SOLVE")
-
- import math_engine
- python_match = re.search(r'```python(.*?)```', res.text, re.DOTALL)
- if python_match:
- python_code = python_match.group(1).strip()
- result = math_engine.run_llm_code(python_code)
- if result["success"]:
- print(f"⚡ [FAST PATH] Python Math Engine execution successful!")
- return await self._format_quick_response(result, student_name)
- else:
- print(f"❌ [FAST PATH] Math Engine execution error: {result.get('error')}")
-
- except Exception as e:
- print(f"❌ [FAST PATH] Error: {e}")
-
- # Fallback to full pipeline
- print(f"⚠️ [FAST PATH] Falling back to full pipeline...")
- return None
-
- async def _format_quick_response(self, data: dict, student_name: str) -> dict:
- """
- V273.0: המרת תשובה מהירה לפורמט הסטנדרטי של האפליקציה
- """
- steps = data.get("steps", [])
- final_answer = data.get("final_answer", "")
- teacher_summary = data.get("teacher_summary", "")
-
- # Build sections format
- formatted_steps = []
- for step in steps:
- formatted_steps.append({
- "step_number": step.get("step_number", len(formatted_steps) + 1),
- "title": f"שלב {step.get('step_number', len(formatted_steps) + 1)}",
- "content_mixed": step.get("content_mixed", ""),
- "block_math": step.get("block_math", "")
- })
-
- sections = [{
- "section_title": "פתרון",
- "steps": formatted_steps,
- "section_result": final_answer
- }]
-
- # Generate audio for summary
- audio_result = None
- if teacher_summary:
- teacher_summary = self._scrub_latex_from_text(teacher_summary)
- teacher_summary = self._sanitize_teacher_response(teacher_summary)
-
- try:
- audio_result = await generate_teacher_audio(teacher_summary)
- except Exception as e:
- print(f"🎙️ [FAST PATH] Audio failed: {e}")
-
- response = {
- "sections": sections,
- "final_answer": final_answer,
- "teacher_closing": f"כל הכבוד {student_name}! 🎉",
- "teacher_summary": teacher_summary
- }
-
- if audio_result:
- if audio_result.startswith("http"):
- response["audio_url"] = audio_result
- else:
- response["audio_base64"] = audio_result
-
- return response
-
- # ===================== V300: FEATURE-TOGGLED OCR =====================
- # OCR_STRIP_MODE=development → Stitch & Strip (single-pass, HD, structured)
- # OCR_STRIP_MODE=production → Legacy Triple-Pass (safe, proven)
-
- def _flatten_ocr_payload(self, ocr_data) -> str:
- """
- V9.0.2: Ensures the OCR data is converted into a single, continuous text string
- regardless of the API response format (JSON string, dict, or list).
- """
- if not ocr_data:
- return ""
-
- # 1. If it's a string, it might be a raw string OR a JSON string
- if isinstance(ocr_data, str):
- s = ocr_data.strip()
- if (s.startswith('[') and s.endswith(']')) or (s.startswith('{') and s.endswith('}')):
- try:
- # Attempt to parse if it's a JSON structured string
- parsed_data = json.loads(s)
- ocr_data = parsed_data # Pass to dict/list handling below
- except json.JSONDecodeError:
- # It's just a regular raw string
- return s
- else:
- return s
-
- # 2. If it's a List (This fixes the V9.0.1 bug!)
- if isinstance(ocr_data, list):
- # Join all elements with a newline/space, ignoring empty items
- # V9.0.2 FIX: Handle both list of strings AND list of dicts (Stitch & Strip)
- parts = []
- for item in ocr_data:
- if isinstance(item, dict):
- # Handle structured block format: {"content": "...", "type": "..."}
- content = item.get("content") or item.get("text") or ""
- if content: parts.append(str(content).strip())
- elif item:
- parts.append(str(item).strip())
-
- if parts:
- return " \n ".join(parts)
- return ""
-
- # 3. If it's a Dictionary
- elif isinstance(ocr_data, dict):
- # Look for a primary text key, otherwise convert the whole dict to string
- res = ocr_data.get("text") or ocr_data.get("content")
- if res:
- return str(res).strip()
- else:
- return " \n ".join([f"{k}: {v}" for k, v in ocr_data.items()])
-
- # 4. Ultimate Fallback for any other type
- return str(ocr_data).strip()
-
- async def transcribe_image(self, image_bytes: bytes) -> str:
- """
- V300: Feature-toggled OCR.
- Returns flat problem_text string. Also stores structured OCR list
- in self._last_ocr_structured for downstream consumers.
- """
- ocr_mode = os.environ.get("OCR_STRIP_MODE", "production").lower()
- print(f"📸 [BIT-LOG] OCR mode: {ocr_mode.upper()}")
-
- # ── V300 NEW: Stitch & Strip ──────────────────────────────────────
- if ocr_mode == "development":
- debug = True # Always save strips while in DEV
- print("📸 🔵 [BIT-LOG] Starting OCR (Stitch & Strip V300)...")
- try:
- structured, confidence = await ocr_strip_engine.transcribe(
- image_bytes=image_bytes,
- vision_model=self.vision_model,
- debug_mode=debug,
- )
- # Side-channel: store structured list for future use
- self._last_ocr_structured = structured
- self._last_ocr_confidence = confidence
- flat = ocr_strip_engine.flatten_to_text(structured)
- print(f"📸 🏆 [BIT-LOG] Stitch & Strip OCR complete — {len(structured)} items, Confidence: {confidence:.2f}")
- return flat
- except Exception as e:
- print(f"📸 ❌ [BIT-LOG] Stitch & Strip failed ({e}), falling back to triple-pass")
-
- # ── LEGACY: Triple-Pass ───────────────────────────────────────────
- print("📸 🔵 [BIT-LOG] Starting OCR (Triple Pass - V231.8)...")
- self._last_ocr_confidence = 0.85 # Default for legacy mode
- prompt = prompts.get_transcription_prompt()
-
- results = []
-
- # Pass 1: Original Image
- try:
- res = await asyncio.wait_for(
- self.vision_model.generate_content_async(
- [prompt, {"mime_type": "image/jpeg", "data": image_bytes}],
- generation_config={"temperature": 0.0}
- ),
- timeout=18.0
- )
- results.append(res.text.strip())
- cost_tracker.log_api_usage(res.usage_metadata, "OCR_PASS_1")
- print(f"📸 🟢 [BIT-LOG] OCR Pass 1 (Original): {len(results[0])} chars")
- except Exception as e:
- print(f"📸 🟡 [BIT-LOG] OCR Pass 1 failed: {e}")
- results.append("Error")
-
- # Pass 2: Enhanced Image
- try:
- enhanced_bytes = self._enhance_image_bytes(image_bytes)
- res = await asyncio.wait_for(
- self.vision_model.generate_content_async(
- [prompt, {"mime_type": "image/jpeg", "data": enhanced_bytes}],
- generation_config={"temperature": 0.0}
- ),
- timeout=18.0
- )
- results.append(res.text.strip())
- cost_tracker.log_api_usage(res.usage_metadata, "OCR_PASS_2")
- print(f"📸 🟢 [BIT-LOG] OCR Pass 2 (Enhanced): {len(results[1])} chars")
- except Exception as e:
- print(f"📸 🟡 [BIT-LOG] OCR Pass 2 failed: {e}")
- results.append("Error")
-
- # Pass 3: Retry with reinforced prompt for complex fractions
- try:
- retry_prompt = prompt + "\n\nCRITICAL: Pay special attention to complex fractions with powers in denominators, like (x^2-16)^2."
- res = await asyncio.wait_for(
- self.vision_model.generate_content_async(
- [retry_prompt, {"mime_type": "image/jpeg", "data": image_bytes}],
- generation_config={"temperature": 0.0}
- ),
- timeout=18.0
- )
- results.append(res.text.strip())
- cost_tracker.log_api_usage(res.usage_metadata, "OCR_PASS_3")
- print(f"📸 🟢 [BIT-LOG] OCR Pass 3 (Retry): {len(results[2])} chars")
- except Exception as e:
- print(f"📸 🟡 [BIT-LOG] OCR Pass 3 failed: {e}")
- results.append("Error")
-
- final_text = self._merge_ocr_results(results)
- # V9.0.2: Flatten payload (Robust handling of Union[str, list, dict])
- final_text = self._flatten_ocr_payload(final_text)
-
- # Build minimal structured list for consistency
- self._last_ocr_structured = [{"type": "text", "content": final_text}]
- print(f"📸 🏆 [BIT-LOG] OCR Final (Merged): {len(final_text)} chars")
- return final_text
-
- def _enhance_image_bytes(self, image_bytes: bytes) -> bytes:
- """V231.4: Attempt high-contrast enhancement. Falls back to original."""
- try:
- from PIL import Image, ImageEnhance
- import io
- img = Image.open(io.BytesIO(image_bytes))
- # High contrast + sharpness for better OCR
- img = ImageEnhance.Contrast(img).enhance(2.0)
- img = ImageEnhance.Sharpness(img).enhance(2.0)
- buf = io.BytesIO()
- img.save(buf, format='PNG')
- return buf.getvalue()
- except Exception as e:
- logging.debug(f"⚠️ [BIT-LOG] Image enhancement failed: {e}")
- # PIL not available or image issue — use original
- return image_bytes
-
- def _merge_ocr_results(self, results: list) -> str:
- """V231.4: Trust the most mathematically detailed OCR result."""
- def _math_complexity(text: str) -> int:
- """Score how much math content a string has."""
- if not text: return 0
- score = 0
- score += text.count('\\frac') * 3
- score += text.count('^') * 2
- score += text.count('\\sin') + text.count('\\cos') + text.count('\\tan')
- score += text.count('\\sqrt') * 2
- score += text.count('\\int') * 3
- score += text.count('סעיף') * 5 # sub-question markers are very valuable
- score += text.count('שאלה') * 5 # top-level headers are very valuable
- score += len(text) // 50 # length bonus
- return score
-
- valid = [r for r in results if r and r != "Error"]
- if not valid:
- return "Error"
-
- scored = [(r, _math_complexity(r)) for r in valid]
- scored.sort(key=lambda x: x[1], reverse=True)
-
- winner = scored[0]
- print(f"📸 🏆 [BIT-LOG] OCR Winner: score={winner[1]} (of {len(valid)} candidates)")
- return winner[0]
-
- async def _extract_key_data(self, problem_text: str, image_data: bytes = None) -> dict:
- """V231.14: Phase 1 - Extract specific values with validation and image support."""
- for attempt in range(1, 3): # 2 attempts
- try:
- print(f"⚓ [BIT-LOG] Data Anchor Extraction (Attempt {attempt}). Image Data: {type(image_data)} {len(image_data) if image_data else 'None'}")
- prompt = prompts.get_data_extraction_prompt(problem_text)
- if not prompt:
- prompt = f"Extract math data from this problem: {problem_text}"
-
- content = [prompt]
- if image_data and isinstance(image_data, bytes):
- # V316.9: Use canonical dict format for maximum SDK compatibility
- content.append({"mime_type": "image/png", "data": image_data})
- print(f"📸 [BIT-LOG] Appended image part (size: {len(image_data)})")
-
- res = await asyncio.wait_for(
- self.model.generate_content_async(
- content,
- generation_config={"temperature": 0.0}
- ),
- timeout=15.0 # 15s timeout per attempt
- )
- cost_tracker.log_api_usage(res.usage_metadata, "DATA_ANCHOR")
- match = re.search(r'\{.*\}', res.text, re.DOTALL)
- if match:
- data = safe_json_loads(match.group())
-
- # V317.5: Robust JSON Handling - Smart Unification
- data = unify_data_anchor(data)
- if isinstance(data, dict):
- print(f"⚓ [BIT-LOG] Unified Data Anchor: {json.dumps(data, ensure_ascii=False)[:100]}...")
-
- # V261.X: Guard against parse-failure sentinel being treated as valid data
- if data and isinstance(data, dict) and data.get('logic_error') and data.get('error_type') == 'PARSING_FAILURE':
- print(f"⚠️ [BIT-LOG] Data Anchor JSON parse failed (Attempt {attempt}/2) — skipping sentinel.")
- continue
-
- # ✅ V231.12: Validate extracted data
- if data and isinstance(data, dict):
- # Validate function equations - must have '=' sign
- if 'function_equations' in data:
- valid_eqs = []
- for eq in data['function_equations']:
- # Must have '=' sign and be longer than just "f(x)"
- if '=' in eq and len(eq) > 5:
- valid_eqs.append(eq)
- else:
- print(f"⚠️ [BIT-LOG] Invalid equation (no '=' or too short): '{eq}'")
-
- data['function_equations'] = valid_eqs
-
- # V1.1: Partial Semantic Recovery logic
- if not valid_eqs:
- # Determine if recovery is possible (e.g., if there's a point or a simple equation)
- has_points = len(data.get('points', [])) > 0
- has_equations = len(data.get('equations', [])) > 0
-
- if has_points or has_equations:
- print(f"🔄 [V1.1] Partial Semantic Recovery: No function f(x) but found data. Continuing...")
- data['anchor_state'] = "PARTIAL_RECOVERABLE"
- else:
- print(f"⚠️ [V1.1] Incomplete data: No function or equations. Recapture likely needed.")
- data['anchor_state'] = "INCOMPLETE"
- else:
- data['anchor_state'] = "FULL"
-
- if not valid_eqs and 'function_equations' in data:
- print(f"⚠️ [BIT-LOG] No valid function equations found in attempt {attempt}!")
-
- print(f"⚓ [BIT-LOG] Data Anchor (Attempt {attempt}): {json.dumps(data, ensure_ascii=False)}")
- return data
- except asyncio.TimeoutError:
- print(f"⚠️ [BIT-LOG] Data Anchor timeout (Attempt {attempt}/2)")
- except Exception as e:
- print(f"⚠️ [BIT-LOG] Data Anchor error (Attempt {attempt}/2): {e}")
- import traceback
- traceback.print_exc()
-
-
- print("🚨 [BIT-LOG] CRITICAL: Data Anchor extraction failed completely!")
- return None
-
- async def _understand_problem(
- self,
- problem_text: str,
- data_anchor: dict
- ) -> dict:
- """
- V231.14: Understand problem structure BEFORE solving.
-
- Analyzes:
- - What type of problem is this?
- - How many sub-questions (א, ב, ג)?
- - What is each sub-question asking?
- - What are the dependencies?
-
- Returns understanding JSON.
- """
- print("📋 [BIT-LOG] Analyzing problem structure...")
-
- try:
- prompt = problem_understanding.get_problem_understanding_prompt(
- problem_text,
- data_anchor
- )
-
- response = await asyncio.wait_for(
- self.model.generate_content_async(prompt),
- timeout=15.0
- )
-
- understanding = problem_understanding.parse_understanding(response.text)
-
- # Validate
- if not problem_understanding.validate_understanding(understanding):
- print("⚠️ [BIT-LOG] Invalid understanding structure, using fallback")
- return self._create_fallback_understanding(problem_text, data_anchor)
-
- # V260.2: Enforce Hard Rules (Locus)
- understanding = problem_understanding.enforce_locus_rule(understanding, problem_text)
-
- print(f"📋 [UNDERSTANDING] Type: {understanding['problem_type']}")
- print(f"📋 [UNDERSTANDING] Sub-questions: {len(understanding['sub_questions'])}")
-
- for sq in understanding['sub_questions']:
- print(f" - {sq['id']}: {sq['topic']}")
-
- return understanding
-
- except Exception as e:
- print(f"❌ [BIT-LOG] Understanding failed: {e}")
- return self._create_fallback_understanding(problem_text, data_anchor)
-
- def _create_fallback_understanding(self, problem_text: str, data_anchor: dict) -> dict:
- """Create simple understanding if analysis fails."""
- return {
- "problem_type": "GENERAL",
- "main_question": "Solve the problem",
- "sub_questions": [{
- "id": "main",
- "question": problem_text,
- "topic": "GENERAL",
- "requires": [],
- "expected_output": "solution"
- }],
- "solving_order": ["main"],
- "dependencies": {}
- }
-
- async def _generate_strategy_card(self, problem_text: str, data_anchor: dict) -> dict:
- """V260.0: Generate high-level strategy card."""
- print("🧭 [BIT-LOG] Generating Strategy Card...")
- try:
- prompt = prompts.get_strategy_card_prompt(problem_text, data_anchor)
- res = await asyncio.wait_for(
- self.model.generate_content_async(prompt),
- timeout=15.0
- )
- cost_tracker.log_api_usage(res.usage_metadata, "STRATEGY_CARD")
- match = re.search(r'\{.*\}', res.text, re.DOTALL)
- if match:
- data = safe_json_loads(match.group())
- print(f"🧭 [BIT-LOG] Strategy generated: {data.get('title')}")
- return self._inject_bidi_markers(data)
- except Exception as e:
- print(f"⚠️ [BIT-LOG] Strategy generation failed: {e}")
- return None
-
- async def _generate_visual_context(self, problem_text: str, category: str, image_data: bytes) -> dict:
- """V300.3: Generate visual description (Sketch). Supports text-only fallback."""
- print("GENERATE: [BIT-LOG] Generating Visual Context (Smart Trigger)...")
- try:
- prompt = prompts.get_visual_context_prompt(problem_text, category)
-
- # V300.3: Multi-modal Support (Handle both Image and Text-Only)
- if image_data:
- # Use vision model with image
- res = await asyncio.wait_for(
- self.vision_model.generate_content_async([
- prompt,
- {"mime_type": "image/png", "data": image_data}
- ]),
- timeout=15.0
- )
- else:
- # V300.3: Text-only schematic generation
- print("INFO: [BIT-LOG] No image provided. Generating sketch from text description.")
- res = await asyncio.wait_for(
- self.vision_model.generate_content_async(prompt),
- timeout=15.0
- )
-
- cost_tracker.log_api_usage(res.usage_metadata, "VISUAL_CONTEXT")
- match = re.search(r'\{.*\}', res.text, re.DOTALL)
- if match:
- data = safe_json_loads(match.group())
- print(f"SUCCESS: [BIT-LOG] Visual context generated: {data.get('title')}")
- return self._inject_bidi_markers(data)
- except Exception as e:
- print(f"ERROR: [BIT-LOG] Visual context generation failed: {e}")
- return None
-
- def _verify_completeness(self, understanding: dict, solutions: list) -> list:
- """V260.0: Check if all sub-questions were solved."""
- required_ids = [sq['id'] for sq in understanding['sub_questions']]
- solved_ids = [sol['sub_question_id'] for sol in solutions]
-
- missing = [rid for rid in required_ids if rid not in solved_ids]
-
- if missing:
- print(f"🕵️♂️ [BIT-LOG] Completeness Check: MISSING {missing}")
- else:
- print("🕵️♂️ [BIT-LOG] Completeness Check: PASSED")
-
- async def _verify_pedagogical_depth(self, llm_response: dict) -> dict:
- """V260.1: The Strict Teacher - Verify step depth and clarity."""
- print("👩🏫 [BIT-LOG] Strict Teacher: Verifying depth...")
-
- # Extract steps content for analysis
- steps_text = "\n".join([f"Step {s.get('step')}: {s.get('content')} | {s.get('block_math')}"
- for s in llm_response.get("steps", [])])
-
- prompt = f"""
- YOU ARE A STRICT MATH TEACHER. Review this student's solution.
-
- Student's Solution Steps:
- {steps_text}
-
- CRITERIA ("Atomic Algebra"):
- 1. did the student skip algebraic steps? (e.g. going from 2x=10 directly to x=5 is BAD. Must show /2).
- 2. Is the Hebrew explanation clear and simple?
- 3. Are there at least 3-4 steps for a complex problem/locus?
- 4. IS 'block_math' POPULATED? Steps must show the math in LaTeX! (e.g., don't just say "we calculate", SHOW the calculation).
-
- OUTPUT JSON:
- {{
- "approved": true/false,
- "feedback": "Specific feedback if rejected (e.g., 'Skipped division step', 'Missing LaTeX in block_math')"
- }}
- """
-
- try:
- res = await asyncio.wait_for(
- self.model.generate_content_async(prompt),
- timeout=10.0
- )
- cost_tracker.log_api_usage(res.usage_metadata, "STRICT_TEACHER_VERIFY")
- match = re.search(r'\{.*\}', res.text, re.DOTALL)
- if match:
- data = safe_json_loads(match.group())
- print(f"👩🏫 [BIT-LOG] Verdict: {'✅ APPROVED' if data.get('approved') else '❌ REJECTED'} ({data.get('feedback')})")
- # V4.2.4: Final Output Sealing (Zero-Leakage Guard)
- import math_intent_detector
- grade_num = math_intent_detector._extract_grade_number(grade)
- data = seal_pedagogical_output(data, grade_num)
-
- return data
- except Exception as e:
- print(f"⚠️ [BIT-LOG] Verification failed: {e}")
-
- # Default to approved if check fails to avoid blocking
- return {"approved": True, "feedback": ""}
-
- # ===================== V272.0: SMART TEACHER SUMMARY =====================
-
- def _generate_deterministic_summary(
- self,
- problem_type: str,
- topics_text: str,
- answers_text: str,
- proof_graph = None
- ) -> str:
- """
- V4.2 (Behavioral Firewall): Deterministic Template Renderer.
- Now used ONLY as fallback if LLM summary fails.
- """
- print("🎙️ [V4.2] Generating DETERMINISTIC teacher summary (fallback)...")
-
- topic = topics_text if topics_text != "מתמטיקה כללית" else "התרגיל ששלחת"
-
- methods = []
- if proof_graph:
- for step in proof_graph.steps:
- if step.logic_description:
- methods.append(step.logic_description)
-
- methods_text = " ו- ".join(list(set(methods))[:2]) if methods else "שיטות פתרון בסיסיות"
-
- template = (
- f"התרגיל עסק ב: {topic}. "
- f"השתמשנו בשיטות: {methods_text}. "
- f"הגענו לתשובה: {answers_text}. "
- f"הטריק לזכור, תמיד כדאי לעבוד שלב אחר שלב בצורה מסודרת. "
- f"כל הכבוד על ההתמדה!"
- )
-
- return template
-
- async def _generate_teacher_summary(
- self,
- problem_text: str,
- all_solutions: list,
- understanding: dict,
- proof_graph = None,
- student_name: str = "תלמיד",
- student_gender: str = "M"
- ) -> dict:
- """
- V285.1: LLM-powered pedagogical summary generator.
- Returns a dict with: topic_summary, key_concepts, formulas_to_remember, tts_speech.
- Falls back to deterministic template on error.
- """
- # Extract answers and topics for context
- final_answers = []
- topics_used = []
- for sol in all_solutions:
- if 'response' in sol and sol['response']:
- resp = sol['response']
- if isinstance(resp, dict):
- if resp.get('final_answer'): final_answers.append(resp['final_answer'])
- if sol.get('topic'): topics_used.append(sol['topic'])
-
- answers_text = ", ".join(final_answers[:3]) if final_answers else "לא נמצאו תשובות"
- topics_text = ", ".join(set(topics_used)) if topics_used else "מתמטיקה כללית"
- problem_type = understanding.get('problem_type', 'GENERAL')
-
- # Try LLM-powered summary
- try:
- print("🎙️ [V285.1] Generating LLM pedagogical summary...")
-
- summary_prompt = prompts.get_teacher_summary_prompt(
- student_name=student_name,
- student_gender=student_gender
- )
-
- # Build context for the LLM
- context = f"""
- הבעיה: {problem_text[:300]}
- סוג בעיה: {problem_type}
- נושאים: {topics_text}
- תשובות סופיות: {answers_text}
- """
-
- response = await asyncio.wait_for(
- self.model.generate_content_async(summary_prompt + "\n\n" + context),
- timeout=30.0
- )
-
- raw_text = response.text.strip()
- print(f"🎙️ [V285.1] LLM Summary received ({len(raw_text)} chars)")
- logger.info(f"🎙️ [V285.1] RAW: {raw_text[:300]}")
-
- match = re.search(r'\{.*\}', raw_text, re.DOTALL)
- if not match:
- raise ValueError("No JSON found in LLM response for teacher summary")
- summary_data = safe_extract_json(match.group(), caller="TEACHER_SUMMARY")
-
- if summary_data.get("error_type") == "PARSING_FAILURE":
- raise ValueError("JSON parsing failed for teacher summary")
-
- # Build structured result
- result = {
- "topic_summary": summary_data.get("topic_summary", topics_text),
- "key_concepts": summary_data.get("key_concepts", []),
- "formulas_to_remember": summary_data.get("formulas_to_remember", []),
- "tts_speech": summary_data.get("tts_speech", ""),
- }
-
- # Build display text (for teacher_summary field in response)
- display_parts = []
- if result["topic_summary"]:
- display_parts.append(f"📚 נושא: {result['topic_summary']}")
- if result["key_concepts"]:
- concepts = "\n".join(f"• {c}" for c in result["key_concepts"])
- display_parts.append(f"💡 תובנות מפתח:\n{concepts}")
- if result["formulas_to_remember"]:
- formulas = "\n".join(f"$${f}$$" for f in result["formulas_to_remember"])
- display_parts.append(f"📐 נוסחאות לזכור:\n{formulas}")
-
- result["display_text"] = "\n\n".join(display_parts)
-
- print(f"🎙️ ✅ [V285.1] Summary ready: {result['topic_summary']}, {len(result['key_concepts'])} concepts")
- return result
-
- except Exception as e:
- print(f"🎙️ ⚠️ [V285.1] LLM summary failed: {e}. Using deterministic fallback.")
- fallback_text = self._generate_deterministic_summary(problem_type, topics_text, answers_text, proof_graph)
- return {
- "topic_summary": topics_text,
- "key_concepts": [],
- "formulas_to_remember": [],
- "tts_speech": fallback_text,
- "display_text": fallback_text
- }
-
- def _get_enhanced_fallback_summary(self, problem_type: str, answers: str) -> str:
- """V272.0: Fallback משופר לפי סוג הבעיה"""
- fallbacks = {
- "FUNCTION_ANALYSIS": "עברנו על חקירת פונקציה מלאה! מצאנו נקודות קיצון, תחומי עלייה וירידה, והבנו את התנהגות הפונקציה. הטריק הוא לזכור שנגזרת אפס מסמנת נקודות קיצון. כל הכבוד!",
- "GEOMETRY": "פתרנו שאלה בגיאומטריה אנליטית! השתמשנו בנוסחאות מרחק ומשוואות של צורות גיאומטריות. תמיד כדאי לצייר סקיצה לפני שמתחילים לחשב. מעולה!",
- "CALCULUS": "עבדנו עם נגזרות ואינטגרלים! זכרו את כללי הגזירה הבסיסיים ואת כלל השרשרת. התרגול הוא המפתח להצלחה בחדוא. כל הכבוד על ההתמדה!",
- "ALGEBRA": "פתרנו משוואות בצורה מסודרת! הדרך היא לבודד את הנעלם צעד אחר צעד. תמיד כדאי לבדוק את התשובה על ידי הצבה חזרה. יופי של עבודה!",
- "TRIGONOMETRY": "עבדנו עם טריגונומטריה! השתמשנו בזהויות טריגונומטריות ובקשרים בין הפונקציות. כדאי לזכור את הזהויות הבסיסיות בעל פה. מצוין!",
- "INVESTIGATION": "עברנו על חקירת פונקציה מלאה! מצאנו תחום, נקודות חיתוך עם הצירים, נגזרנו ומצאנו קיצון. הטריק הוא לעבוד בשיטתיות - שלב אחרי שלב. כל הכבוד!",
- }
-
- base = fallbacks.get(problem_type, "פתרנו את התרגיל צעד אחר צעד בצורה מסודרת. כל שלב הוביל לשלב הבא עד שהגענו לתשובה. כל הכבוד על ההתמדה!")
-
- if answers and answers != "לא נמצאו תשובות":
- base += f" הגענו לתשובה: {answers[:50]}."
-
- return base
-
- # ===================== V285.0: CHECK ME (HOMEWORK VERIFICATION) =====================
-
- async def _check_student_work(self, image_data_list: List[bytes], grade: str, student_name: str,
- student_gender: str = "M", question_id: str = "q_check"):
- """
- V285.0: Dedicated pipeline for the "Check Me" feature.
- Sends the raw image to Gemini Vision with the check-me prompt.
- The LLM acts as a homework checker, NOT a solver.
- """
- print(f"📝 [CHECK-ME] Starting homework verification for {student_name}")
-
- # Step 1: Emit WORKING state
- yield BuddyEvent(
- question_id=question_id,
- state=BuddyState.SECTION_WORKING,
- current_section_id="CHECK",
- payload={"status": "המורה בודקת את העבודה שלך... 📝"}
- )
-
- try:
- # V311.0: Data Slicing Guardrail
- # First, transcribe and extract the "Absolute Truth" of the problem from the FIRST image
- image_data = image_data_list[0]
- print("📝 [CHECK-ME] Step 1.5: Extracting Problem Data (Data Slicing from image_00)...")
- problem_text = await self.transcribe_image(image_data)
- data_anchor = await self._extract_key_data(problem_text, image_data=image_data)
-
- # Step 2: Build check-me prompt and send to Vision LLM
- check_prompt = prompts.get_check_me_prompt(
- grade=grade,
- student_name=student_name,
- student_gender=student_gender,
- data_anchor=data_anchor
- )
-
- # Prepare images for Gemini Vision
- vision_content = [check_prompt]
- for img_bytes in image_data_list:
- vision_content.append({"mime_type": "image/png", "data": img_bytes})
-
- print(f"📝 [CHECK-ME] Sending {len(image_data_list)} images + check prompt to Vision LLM...")
-
- response = await asyncio.wait_for(
- self.vision_model.generate_content_async(vision_content),
- timeout=60.0
- )
-
- raw_text = response.text.strip()
- print(f"📝 [CHECK-ME] LLM Response received ({len(raw_text)} chars)")
- logger.info(f"📝 [CHECK-ME] RAW LLM: {raw_text[:500]}")
-
- # Step 3: Parse JSON using the canonical safe_extract_json
- match = re.search(r'\{.*\}', raw_text, re.DOTALL)
- if not match:
- print("📝 ❌ [CHECK-ME] No JSON found in LLM response")
- yield BuddyEvent(
- question_id=question_id,
- state=BuddyState.ERROR,
- payload=build_standard_response(
- final_answer="מצטערת, לא הצלחתי לפענח את הניתוח. נסו שוב? 🔄",
- logic_error=True,
- )
- )
- return
- check_result = safe_extract_json(match.group(), caller="CHECK_ME")
-
- if check_result.get("error_type") == "PARSING_FAILURE":
- print("📝 ❌ [CHECK-ME] JSON parsing failed, returning error")
- yield BuddyEvent(
- question_id=question_id,
- state=BuddyState.ERROR,
- payload=build_standard_response(
- final_answer="מצטערת, לא הצלחתי לפענח את הניתוח. נסו שוב? 🔄",
- logic_error=True,
- )
- )
- return
-
- # Step 4: Extract fields from LLM response
- verdict = check_result.get("verdict", "has_errors")
- score = check_result.get("score", 0)
- mistakes = check_result.get("mistakes", [])
- encouragement = check_result.get("encouragement", "")
- problem_identified = check_result.get("problem_identified", "")
- methodology_ok = check_result.get("methodology_ok", True)
- methodology_note = check_result.get("methodology_note", "")
- visual_note = check_result.get("visual_note")
- correct_answer = check_result.get("correct_final_answer", "")
- feedback_steps = check_result.get("feedback_steps", [])
-
- print(f"📝 ✅ [CHECK-ME] Verdict: {verdict}, Steps: {len(feedback_steps)}")
-
- # ═══════════════════════════════════════════════════════════
- # Step 5: Emit STRATEGY_READY — Methodology card
- # ═══════════════════════════════════════════════════════════
- strategy_content = f"**תרגיל שזוהה:** ${problem_identified}$\n\n"
- if methodology_ok:
- strategy_content += f"✅ **השיטה נכונה:** {methodology_note}" if methodology_note else "✅ **השיטה שנבחרה נכונה!**"
- else:
- strategy_content += f"❌ **בעיה בשיטה:** {methodology_note}"
-
- strategy_card = {
- "section_title": "בדיקת שיטת פתרון 🔍",
- "bullets": [strategy_content]
- }
-
- # Generate TTS audio for encouragement
- audio_url = None
- if encouragement:
- try:
- tts_text = self._scrub_latex_from_text(encouragement)
- tts_text = self._sanitize_teacher_response(tts_text)
- if tts_text:
- print(f"🎙️ [CHECK-ME] Generating TTS for: {tts_text[:60]}...")
- audio_url = await generate_teacher_audio(tts_text, output_path=None)
- except Exception as e:
- print(f"🎙️ ❌ [CHECK-ME] TTS failed: {e}")
-
- strategy_payload = {"strategy_card": strategy_card, "teacher_summary": encouragement}
- if audio_url:
- if audio_url.startswith("http"):
- strategy_payload["audio_url"] = audio_url
- else:
- strategy_payload["audio_base64"] = audio_url
-
- yield BuddyEvent(
- question_id=question_id,
- state=BuddyState.STRATEGY_READY,
- payload=strategy_payload
- )
-
- # ═══════════════════════════════════════════════════════════
- # Step 6: Emit SECTION_READY per feedback step
- # ═══════════════════════════════════════════════════════════
- for fb_step in feedback_steps:
- step_id = fb_step.get("step_id", 0)
- student_wrote = fb_step.get("student_wrote", "")
- is_correct = fb_step.get("is_correct", True)
- error_desc = fb_step.get("error_description", "") or ""
- should_be = fb_step.get("should_be", "") or ""
-
- # Build rich content for this step
- if is_correct:
- title = f"✅ שלב {step_id} — נכון!"
- content = f"מה שכתבת: ${student_wrote}$" if student_wrote else "נכון!"
- if error_desc:
- content += f"\n\n{error_desc}"
- else:
- title = f"❌ שלב {step_id} — יש טעות"
- content = f"**מה שכתבת:** ${student_wrote}$\n\n" if student_wrote else ""
- content += f"**הטעות:** {error_desc}"
- if should_be:
- content += f"\n\n**מה שצריך להיות:** ${should_be}$"
-
- section_data = {
- "section_title": title,
- "steps": [{
- "step_id": step_id,
- "content_mixed": content,
- "block_math": ""
- }]
- }
-
- yield BuddyEvent(
- question_id=question_id,
- state=BuddyState.SECTION_READY,
- current_section_id=f"CHECK_{step_id}",
- payload=section_data
- )
-
- # ═══════════════════════════════════════════════════════════
- # Step 7: Visual note (if any)
- # ═══════════════════════════════════════════════════════════
- if visual_note:
- yield BuddyEvent(
- question_id=question_id,
- state=BuddyState.SECTION_READY,
- current_section_id="CHECK_VISUAL",
- payload={
- "section_title": "הערה על שרטוט 📐",
- "steps": [{
- "step_id": 99,
- "content_mixed": visual_note,
- "block_math": ""
- }]
- }
- )
-
- # ═══════════════════════════════════════════════════════════
- # Step 8: COMPLETE with final answer & Protocol Alignment
- # ═══════════════════════════════════════════════════════════
- from pedagogical_builder import sanitize_math_text
-
- # V311.0: LaTeX UI Safety
- safe_correct_answer = sanitize_math_text(correct_answer) if correct_answer else ""
- if safe_correct_answer and not safe_correct_answer.startswith("$$") and not safe_correct_answer.startswith("$"):
- safe_correct_answer = f"$${safe_correct_answer}$$"
-
- if verdict == "correct":
- final_answer_text = f"✅ כל הכבוד! הפתרון נכון! {encouragement}"
- elif verdict == "unreadable":
- final_answer_text = "📸 לא הצלחתי לקרוא את כתב היד. נסו לצלם שוב בצורה ברורה יותר."
- elif verdict == "methodology_error":
- final_answer_text = f"📐 יש בעיה בשיטת הפתרון. {methodology_note}"
- else:
- final_answer_text = f"📝 התשובה הנכונה: {safe_correct_answer}" if safe_correct_answer else encouragement
-
- yield BuddyEvent(
- question_id=question_id,
- state=BuddyState.COMPLETE,
- payload={
- "final_answer": final_answer_text,
- "verdict": verdict,
- "is_correct": verdict == "correct",
- "score": score,
- "mistakes": mistakes,
- "feedback": encouragement, # Protocol Alignment
- "correct_answer": safe_correct_answer, # Protocol Alignment
- "problem_identified": problem_identified
- }
- )
-
- except asyncio.TimeoutError:
- print("📝 ❌ [CHECK-ME] LLM timeout (60s)")
- yield BuddyEvent(
- question_id=question_id,
- state=BuddyState.ERROR,
- payload=build_standard_response(
- final_answer="הבדיקה לקחה יותר מדי זמן. נסו שוב? ⏱️",
- logic_error=True,
- )
- )
-
- except Exception as e:
- logger.exception("CHECK-ME ERROR")
- print(f"📝 ❌ [CHECK-ME] Error: {e}")
- yield BuddyEvent(
- question_id=question_id,
- state=BuddyState.ERROR,
- payload=build_standard_response(
- final_answer="אופס! משהו השתבש בבדיקה. נסו שוב? 🔄",
- logic_error=True,
- )
- )
-
- # ===================== SMART SOLVE =====================
-
-
- async def smart_solve(
- self,
- problem_text: str,
- data_anchor: dict,
- grade: str,
- category: str,
- student_name: str,
- student_gender: str = "M",
- image_data: bytes = None,
- image_data_list: list = None,
- ambiguity_warning: bool = False,
- processing_strategy: ProcessingStrategy = None,
- question_id: str = "q_default",
- **kwargs
- ):
- """
-
- Workflow:
- 1. Understand problem structure
- 2. Solve each sub-question (WITH IMAGE!)
- 3. Build comprehensive response
- """
- print("🎯 [BIT-LOG] Using SMART SOLVE (V231.15)")
- uid = kwargs.get('uid')
- assessment_sent = False
-
- try:
- # Heartbeat: Strategy/Planning
- yield BuddyEvent(
- question_id=question_id,
- state=BuddyState.SECTION_WORKING,
- current_section_id="בניית אסטרטגיה",
- payload={"status": "המורה בונה אסטרטגיה לפתרון..."}
- )
-
- # Step 1: Understand problem structure
- understanding = await self._understand_problem(problem_text, data_anchor)
-
- # V8.9.6: SLICING FAILSAFE (Token Burner Prevention)
- # If this is a function investigation but the Data Anchor has NO functions,
- # we must NOT slice into 9 sub-questions. We force Single-Shot Fallback.
- is_investigation = understanding.get("problem_type") in ["FUNCTION_ANALYSIS", "INVESTIGATION", "CALCULUS"]
- has_anchor_functions = bool(data_anchor.get("function_equations"))
-
- if is_investigation and not has_anchor_functions:
- print("🛑 [V8.9.6] Slicing Failsafe Triggered: No functions in Anchor for Investigation. Forcing Single-Shot.")
- understanding = self._create_fallback_understanding(problem_text, data_anchor)
-
- # V260.0: Generate Pedagogical Context (Strategy & Visuals)
- strategy_card = await self._generate_strategy_card(problem_text, data_anchor)
-
- # V8.6.9: Wrap in sections list so solution_screen.dart can correctly parse it
- yield BuddyEvent(
- question_id=question_id,
- state=BuddyState.STRATEGY_READY,
- payload={"sections": [strategy_card]} if strategy_card else {}
- )
-
- # V300.3: Smart Visual Triggers (Product Alignment)
- # The goal: Trigger a sketch if explicitly requested or if it's a visual category without an original image.
- visual_categories = ["GEOMETRY", "GEOMETRY_ANALYTIC", "TRIGONOMETRY", "INVESTIGATION", "FUNCTIONS", "GEOMETRIC_LOCUS", "CALCULUS"]
- problem_type = understanding.get("problem_type", "").upper()
-
- # Explicit drawing keywords in text (Supports Dutch/Hebrew variations)
- explicit_drawing_keywords = ["שרטט", "סרטט", "סקיצה", "גרף", "המחשה", "צייר", "כיוון", "שרטוט", "איזה מן הגרפים", "איזה מהגרפים", "איזה גרף מתאר"]
- is_explicitly_requested = any(keyword in problem_text for keyword in explicit_drawing_keywords)
-
- # V300.3: Also check individual sub-questions for explicit requests
- if not is_explicitly_requested and "sub_questions" in understanding:
- for sub_q in understanding["sub_questions"]:
- if any(kw in sub_q.get("question", "") for kw in explicit_drawing_keywords):
- is_explicitly_requested = True
- break
-
- # Helper sketch: Visual category + no original image provided (or manual request)
- has_original_image = bool(image_data)
- needs_helper_sketch = (problem_type in visual_categories) and (not has_original_image)
-
- visual_context = None
- if is_explicitly_requested or needs_helper_sketch:
- print(f"TRIGGER: [V300.3] Visual Context Triggered: Explicit={is_explicitly_requested}, Helper={needs_helper_sketch}")
- visual_context = await self._generate_visual_context(problem_text, problem_type or category, image_data)
-
- graph_svg = None
- if visual_context and visual_context.get("latex_input"):
- print("📉 [V290.0] Generating Chalkboard SVG...")
- try:
- # V302.0: HOTFIX - SYMPY_PARSE_ERROR guard
- # If the input is just the d1=d2 placeholder (from prompt instructions), skip it.
- if "d_1" in visual_context["latex_input"] and "=" in visual_context["latex_input"]:
- print("📉 [V302.0] Skipping placeholder d1=d2 equation to avoid SymPy parse error.")
- graph_svg = None
- else:
- graph_svg = visuals.generate_plot(
- visual_context["latex_input"],
- problem_text,
- visual_context.get("geometric_entities")
- )
- except Exception as e:
- print(f"⚠️ [V302.0] SYMPY_PARSE_ERROR: Graph generation failed: {e}")
- graph_svg = None
-
- # Step 2: Solve each sub-question
- all_solutions = []
- context = {} # Store results for dependencies
- total_tokens = 0 # V8.5: Token Firewall Tracking
-
- for sub_q in understanding['sub_questions']:
- print(f"🔄 [SOLVING] Sub-question {sub_q['id']}: {sub_q['topic']}")
-
- # V8.5: Emit SECTION_WORKING
- yield BuddyEvent(
- question_id=question_id,
- state=BuddyState.SECTION_WORKING,
- current_section_id=sub_q['id'],
- payload={"question": sub_q['question']}
- )
-
- # Solve this sub-question WITH IMAGE
- # V231.22: FIX - Use the detected problem_type from understanding as the category!
- # This fixes the bug where FUNCTION_ANALYSIS was treated as GEOMETRY because we passed the stale 'category' arg.
- effective_category = understanding.get('problem_type', category)
-
- # V4.2 PRE-CONSTRAINT LOGIC (Iron Law #1)
- # 1. Fetch Curriculum Rules FIRST
- grade_num = math_intent_detector._extract_grade_number(grade)
- curriculum_rules = curriculum_engine.get_allowed_math_operators(grade, level=kwargs.get('level', '4'))
-
- # 2. Detect Intent & Lock Strategy (Iron Law #2 - V4.2.8)
- intent = math_intent_detector.detect_intent(sub_q['question'], grade_num)
- strategy_vector = strategy_policy_engine.get_allowed_strategies(intent)
- intent_contract = math_intent_detector.get_intent_contract(intent, grade_num)
-
- # Merge intent_contract with strategy_policy_engine (policy takes precedence)
- if strategy_vector.get("forbidden"):
- intent_contract["forbidden_strategies"] = list(set(intent_contract.get("forbidden_strategies", []) + strategy_vector["forbidden"]))
-
- # 3. SmartSolver Execution (WITH IMAGE! - Strategy Enforcement)
- ocr_confidence = self._last_ocr_confidence
-
- # 1. יצירת אובייקט ה-Context
- eqs = data_anchor.get("function_equations", [])
- joined_eqs = " , ".join(eqs) if eqs else sub_q['question']
-
- # V6 Polish: P0 Canonicalize Math OCR
- cleaned_math = MathCanonicalizer.preprocess_ocr_string(joined_eqs)
-
- context_obj = PipelineContext(
- grade=grade,
- grade_num=math_intent_detector._extract_grade_number(grade),
- topic="GENERAL",
- math_input=cleaned_math,
- confidence=ocr_confidence,
- category=effective_category,
- original_text=problem_text,
- sub_question_text=sub_q.get('question', '') # V7.3: scope isolation
- )
-
- # V6.1 Phase 1: Complexity Estimation & Prompt Specialization
- complexity_score = CurriculumClassifier.estimate_complexity(cleaned_math)
- prompt_specialization = CurriculumClassifier.get_prompt_specialization(grade, effective_category)
-
- # ==================== V7.2 PRE-FLIGHT CHECKS ====================
-
- # Ticket 1: Build AST metadata (Planner gets this; never gets raw math)
- ast_metadata = build_ast_metadata(cleaned_math, effective_category)
- ast_registry = ast_metadata.pop("ast_registry") # Server-side only
- visual_context = _abstract_visual_context(data_anchor or {})
- if visual_context:
- ast_metadata["visual_context"] = visual_context
-
- # Ticket 1: CRS Pre-Flight Gate (BEFORE any LLM call)
- preflight_risk = CognitiveRiskEngine.calculate_risk_score(
- cleaned_math, [], retry_count=0, validation_errors=0
- )
- preflight_crs = preflight_risk["risk_score"]
- print(f"🔬 [PREFLIGHT] Pre-LLM CRS: {preflight_crs} (threshold: 0.7)")
-
- if preflight_crs > 0.7:
- print(f"🛑 [PREFLIGHT] CRS {preflight_crs} exceeds threshold. Skipping Planner. Entering Hint Mode.")
- telemetry.emit_crs_block(preflight_crs, data_anchor.get("problem_id", "unknown") if data_anchor else "unknown")
- telemetry.emit_runtime_outcome("crs_block")
- yield BuddyEvent(
- question_id=question_id,
- state=BuddyState.COMPLETE,
- payload=build_standard_response(
- final_answer="אפשר לקבל רמז? 🎓",
- teacher_summary="זהו תרגיל מאתגר! בוא נפרק אותו לחלקים קטנים יותר או נפשט את הביטוי המרכזי.",
- logic_error=False
- )
- )
- return
-
- # ==================== END PRE-FLIGHT ====================
-
- # ── Guard Clause ─────────────────────────────────────────────
- if not hasattr(context_obj, "math_input"):
- raise ValueError("PipelineContext contract violation")
-
- import datetime
- problem_id_tag = data_anchor.get("problem_id", "adhoc_request") if data_anchor else "adhoc_request"
-
- # ══════════════════════════════════════════════════════════════
- # THE HYBRID ENGINE (LLM NAVIGATES, POLYGRAPH CONTROLS)
- # ══════════════════════════════════════════════════════════════
- print("🧠 [V7.3] Triggering LLM Navigation Mode...")
-
- # ══════════════════════════════════════════════════════════════
- # V8.5: THE MICRO-AGENT SECTION LOOP (RETRY + ESCAPE HATCH)
- # ══════════════════════════════════════════════════════════════
-
- attempts = 0
- max_attempts = 2
- solved_data = None
- last_error_context = ""
- is_degraded = False
- degraded_reason = None
-
- while attempts < max_attempts:
- attempts += 1
-
- # V8.5: PRE-FLIGHT TOKEN FIREWALL
- if total_tokens > GLOBAL_TOKEN_LIMIT:
- print(f"🛑 [FIREWALL] Token limit reached ({total_tokens} > {GLOBAL_TOKEN_LIMIT}). Aborting solve.")
- break
-
- print(f"🧠 [V8.5] Solving sub-q {sub_q['id']} (Attempt {attempts}/{max_attempts})")
-
- # 1. ה-LLM פותר ומנווט את התשובה בהתבסס על ההקשר (והשגיאות הקודמות אם יש)
- solve_prompt = sub_q['question']
- if last_error_context:
- solve_prompt = f"FIX ERROR: {last_error_context}\n\nORIGINAL QUESTION: {solve_prompt}"
-
- # V8.6.9: Reset context on retry to reduce token pressure
- # V310.0: PHYSICAL DATA ISOLATION (The Pink Elephant Fix)
- # Instead of just prompting, we physically remove values belonging to other sub-questions.
-
- all_sub_q_values = []
- if "sub_questions" in understanding:
- for other_q in understanding["sub_questions"]:
- if other_q.get("specific_values"):
- all_sub_q_values.extend(other_q["specific_values"])
-
- # Values that are unique to specific sections (not truly global)
- restricted_pool = set(all_sub_q_values)
-
- local_anchor = {**data_anchor}
- global_values = local_anchor.get("specific_values", [])
-
- # Truly global = anchor values minus ANY value identified as section-specific
- truly_global_values = [v for v in global_values if v not in restricted_pool]
-
- # Section-specific = the values identified for THIS sub-question
- my_specific_values = sub_q.get("specific_values", [])
-
- print(f"📦 [V310.0] Data Slicing for {sub_q['id']}: Global={len(truly_global_values)}, Local={len(my_specific_values)}")
- local_anchor["specific_values"] = list(set(truly_global_values + my_specific_values))
-
- effective_context = {**local_anchor, **context} if attempts == 1 else {**local_anchor}
-
- result = await safe_llm_call(
- lambda: self.strategy_manager.solve_with_strategy(
- problem_text=solve_prompt,
- data_anchor=effective_context,
- grade=grade,
- image_data=image_data,
- image_data_list=image_data_list,
- parent_category=effective_category,
- student_name=student_name,
- student_gender=student_gender
- ),
- timeout_seconds=30.0
- )
-
- if isinstance(result, dict) and result.get("logic_error"):
- # Fatal LLM/JSON error, don't retry same error
- last_error_context = "LLM_MALFORMED_JSON_OR_TIMEOUT"
- continue
-
- llm_resp = result.get("llm_response", {})
-
- # V8.5: Token Accumulation
- usage = llm_resp.get("usage_metadata")
- if usage:
- # V8.5.1: usage is now a dict (serialized in strategy_manager)
- t_count = usage.get('total_token_count', 0) if isinstance(usage, dict) else getattr(usage, 'total_token_count', 0)
- total_tokens += t_count
- print(f"💰 [FIREWALL] Cumulative tokens: {total_tokens}")
-
- llm_steps = llm_resp if isinstance(llm_resp, list) else llm_resp.get("steps", [])
-
- # 2. השרת שולט: הפעלת ה-Polygraph על הצעדים של ה-LLM
- struct_ok, struct_reason = await MathPolygraph.validate_step_sequence(llm_steps, topic=sub_q.get('topic', 'GENERAL'))
-
- poly_ok = struct_ok
- poly_reason = struct_reason
-
- if struct_ok:
- # V1.3: Also verify algebraic consistency (e.g. A + B = C)
- alg_ok, alg_reason = await MathPolygraph.verify_algebraic_consistency(llm_steps, topic=sub_q.get('topic', 'GENERAL'))
- if not alg_ok:
- poly_ok = False
- poly_reason = alg_reason
-
- if poly_ok:
- print(f"✅ [V8.5] Sub-q {sub_q['id']} Validated Successfully (Structure & Algebra)!")
- solved_data = llm_resp
- break
- else:
- print(f"🛑 [V8.5] Validation failed on attempt {attempts}/{max_attempts}: {poly_reason}")
- last_error_context = poly_reason
-
- # HOTFIX: לא זורקים תשובה טובה לפח! שומרים את הפתרון של ה-LLM
- solved_data = llm_resp
-
- # מעקף: אם השגיאה היא רק בעיית קריאה של סימנים (אי-שוויונים/חיצים), סומכים על ה-LLM ויוצאים
- if "SYMPY_PARSE_ERROR" in str(poly_reason):
- # V280.0 + V310.0: Smart Retry & Soft Fail with JSON Security check
- # 1. Logic: Only allow bypass if it's NOT the first attempt OR it's a "Soft Fail" case.
- # 2. Pedagogical: "אין פתרון" is allowed. "לא ייתכן" remains removed.
- forbidden_words = ["סתירה בנתונים", "לא הגיוני", "שגיאה בחישוב שלי", "אני מזהה סתירה", "סתירה", "המצאה", "טעות שלי", "מתנצל"]
- import json
- response_text = json.dumps(llm_resp, ensure_ascii=False)
-
- has_forbidden = any(word in response_text for word in forbidden_words)
-
- if has_forbidden:
- print(f"🛑 [ROBUSTNESS] Forbidden word detected in SYMPY_PARSE_ERROR response. Not Trusting LLM.")
- is_degraded = True
- degraded_reason = "polygraph_fail_forbidden_words"
- # Continue to next attempt
- else:
- # V317.8 Soft Fail: Treat SymPy Parse Error as a warning immediately to avoid retries on valid LaTeX
- print(f"🛡️ [SOFT FAIL] SymPy Parse Error detected (Attempt {attempts}). No forbidden words found. Trusting LLM output for sub-q {sub_q['id']}.")
- is_degraded = True
- degraded_reason = "sympy_soft_fail"
- break # Exit the attempt loop
- elif attempts == max_attempts:
- print(f"⚠️ [HOTFIX] Max attempts reached. Forcing LLM response despite Polygraph failure.")
- is_degraded = True
- degraded_reason = "max_attempts_polygraph_fail"
-
- # 3. Escape Hatch Injection (if failed twice)
- if not solved_data:
- is_degraded = True
- degraded_reason = "polygraph_fail"
- print(f"🛡️ [V8.5] ESCAPE HATCH TRIGGERED for sub-q {sub_q['id']}")
-
- # V8.5.1: If we have LLM steps but they failed validation, use them anyway
- # as a degraded fallback instead of the hardcoded d_1 = d_2.
- if llm_steps:
- solved_data = {
- "steps": llm_steps,
- "final_answer": llm_resp.get("final_answer") if isinstance(llm_resp, dict) else "בדוק את הצעדים"
- }
- # Add a disclaimer to the first step if possible
- if solved_data["steps"] and isinstance(solved_data["steps"][0], dict):
- orig_text = solved_data["steps"][0].get("explanation_text", "")
- solved_data["steps"][0]["explanation_text"] = f"הצגנו את הצעדים העיקריים כדי שתוכל לעקוב אחרי הדרך:\n\n{orig_text}"
- else:
- solved_data = {
- "steps": [
- {
- "step_id": 1,
- "explanation_text": "החישוב בסעיף זה הפך למורכב מאוד. הצגנו את העיקרון המנחה:",
- "math_latex": "d_1 = d_2"
- }
- ],
- "final_answer": "המשך לפי השלבים"
- }
-
- # 4. Packaging & Yielding
- # V8.6.7 FIX / V317.5: Only pass the final answer text forward to prevent massive JSON injection in future prompts
- context[f"result_{sub_q['id']}"] = solved_data.get("final_answer", "No valid answer extracted") if isinstance(solved_data, dict) else "הושלם"
-
- # Check for critical failure (No valid answer extracted)
- # If a section fails completely, we break the loop to avoid cascading failures
- if context[f"result_{sub_q['id']}"] == "No valid answer extracted":
- print(f"🛑 [ABORT] Section {sub_q['id']} failed completely. Aborting orchestration.")
- yield BuddyEvent(
- question_id=question_id,
- state=BuddyState.ERROR,
- payload={"error": "Section failure", "message": "חלה שגיאה בעיבוד חלק מהשאלה. עוצרים כדי למנוע טעויות."}
- )
- return
-
- # AI Assessment Telemetry Extraction
- if not assessment_sent and isinstance(solved_data, dict) and "assessment" in solved_data:
- assessment_data = solved_data["assessment"]
- if uid and assessment_data:
- try:
- from analytics import analytics_manager
- loop = asyncio.get_event_loop()
- loop.create_task(asyncio.to_thread(analytics_manager.update_weekly_analytics, uid, assessment_data))
- print(f"📊 [ANALYTICS] Triggered background telemetry for {uid}")
- assessment_sent = True
- except Exception as e:
- print(f"❌ [ANALYTICS] Failed to trigger background task: {e}")
-
- response = build_pedagogical_response(
- effective_category,
- solved_data,
- data_anchor,
- custom_title=f"סעיף {sub_q['id']}",
- processing_strategy=ProcessingStrategy.HEURISTIC_DEDUCTION
- )
-
- # V8.5: Inject degradation flags into payload
- if is_degraded:
- response["is_degraded"] = True
- response["degraded_reason"] = degraded_reason
-
- # V290.0: Inject graph into section payload if available (Early Projection)
- if graph_svg:
- response["graph_svg"] = graph_svg
- response["graph_base64"] = "" # Legacy fallback
-
- # Emit SECTION_READY
- yield BuddyEvent(
- question_id=question_id,
- state=BuddyState.SECTION_READY,
- current_section_id=sub_q['id'],
- payload=response
- )
-
- all_solutions.append({
- "sub_question_id": sub_q['id'],
- "question": sub_q['question'],
- "topic": sub_q['topic'],
- "response": response
- })
-
- # Step 3: Build multi-part response (V4.2.16 Hotfix)
- final_response = self._build_multi_part_response(
- all_solutions,
- strategy_card=strategy_card,
- visual_context=visual_context
- )
-
- # V260.2: FIX MISSING GRAPH (Forensic Finding 2026-02-14)
- if visual_context:
- print("📉 [BIT-LOG] Generating graph from visual context...")
- try:
- # Note: visuals.generate_plot is synchronous in visuals.py V231.10
- latex_for_plot = visual_context.get("latex_input", "")
- if latex_for_plot:
- # V261.2: Pass Explicit Geometric Entities
- geometric_entities = visual_context.get("geometric_entities")
- graph_svg = visuals.generate_plot(latex_for_plot, problem_text, geometric_entities)
- if graph_svg:
- final_response["graph_svg"] = graph_svg
- final_response["graph_base64"] = "" # Legacy fallback
- print(f"📉 [BIT-LOG] SVG Graph generated! ({len(graph_svg)} chars)")
- else:
- print("📉 [BIT-LOG] Graph generation returned None/Empty")
- else:
- print("📉 [BIT-LOG] No latex_input in visual context. Skipping graph.")
- except Exception as e:
- print(f"⚠️ [BIT-LOG] Graph generation failed: {e}")
-
- # ===================== V285.1: SMART TEACHER SUMMARY + TTS =====================
- print("🎙️ [BIT-LOG] Starting V285.1 Smart Teacher Summary...")
-
- # Generate pedagogical summary using LLM
- summary_result = await self._generate_teacher_summary(
- problem_text,
- all_solutions,
- understanding,
- proof_graph=None,
- student_name=student_name,
- student_gender=student_gender
- )
-
- # Extract TTS text (clean, no LaTeX, no emojis)
- tts_text = summary_result.get("tts_speech", "")
- if tts_text:
- tts_text = self._scrub_latex_from_text(tts_text)
- tts_text = self._sanitize_teacher_response(tts_text)
-
- # Generate Audio from TTS text
- if tts_text:
- print(f"🎙️ [BIT-LOG] Generating Audio for: {tts_text[:60]}...")
- try:
- audio_result = await generate_teacher_audio(tts_text, output_path=None)
-
- if audio_result:
- if audio_result.startswith("http"):
- final_response["audio_url"] = audio_result
- print(f"🎙️ ☁️ [BIT-LOG] Audio uploaded: {audio_result}")
- else:
- final_response["audio_base64"] = audio_result
- print(f"🎙️ 💿 [BIT-LOG] Audio as Base64")
- else:
- print("🎙️ ⚠️ [BIT-LOG] Audio generation returned None")
- except Exception as e:
- print(f"🎙️ ❌ [BIT-LOG] Audio CRASHED: {e}")
-
- # Store the structured summary matching Flutter's _buildSummaryCard format
- # Flutter expects: {audio_pitch, key_concepts, formulas}
- topic_summary = summary_result.get("topic_summary", "")
- key_concepts = summary_result.get("key_concepts", [])
- formulas = summary_result.get("formulas_to_remember", [])
-
- # Build the audio_pitch text: topic + TTS speech
- audio_pitch_parts = []
- if topic_summary:
- audio_pitch_parts.append(f"נושא השיעור: {topic_summary}")
- if tts_text:
- audio_pitch_parts.append(tts_text)
- audio_pitch = " ".join(audio_pitch_parts) if audio_pitch_parts else ""
-
- structured_summary = {
- "audio_pitch": audio_pitch,
- "key_concepts": key_concepts,
- "formulas": formulas,
- }
-
- if audio_pitch or key_concepts or formulas:
- final_response["teacher_summary"] = structured_summary
- print(f"🎙️ ✅ [V285.1] Structured summary set: topic={topic_summary}, concepts={len(key_concepts)}, formulas={len(formulas)}")
- else:
- print("🎙️ [BIT-LOG] No summary data generated")
- # ===================== END V285.1 TTS BLOCK =====================
-
- # V5.10.0: Save to History if Premium
- tier = kwargs.get('tier', 'student_basic')
- # Variable uid is already defined at start of smart_solve
- print(f"🔍 [DEBUG HISTORY] UID: {uid}, Received Tier: '{tier}', kwargs keys: {list(kwargs.keys())}")
- is_premium = tier in ["premium", "admin", "admin_unlimited"]
- if is_premium and uid:
- try:
- # V315.0: Explicit scheduling with loop check
- loop = asyncio.get_event_loop()
- loop.create_task(self._save_exercise_history(uid, problem_text, all_solutions))
- print(f"📚 [HISTORY] History saving scheduled for {uid}")
- except Exception as e:
- print(f"❌ [HISTORY] Failed to schedule history saving: {e}")
-
- # V277.0: FIXED - Yield final solution as a COMPLETE event instead of using return (which is ignored by async generators)
- yield BuddyEvent(
- question_id=question_id,
- state=BuddyState.COMPLETE,
- payload=final_response
- )
- return
-
- except Exception as e:
- print(f"❌ [BIT-LOG] Smart solve error: {e}")
- import traceback
- traceback.print_exc()
-
- # Logic Error Enforced Fallback
- yield BuddyEvent(
- question_id=question_id,
- state=BuddyState.ERROR,
- payload=build_standard_response(
- final_answer="הסבר זה הוסר עקב חריגה מהחוזה.",
- teacher_summary="חלה שגיאה טכנית במערכת המתמטית. אנא נסה לצלם שוב.",
- logic_error=True
- )
- )
- return
-
- def _build_multi_part_response(self, solutions, strategy_card=None, visual_context=None):
- """V6 Polish: Aggregator Refactoring - Isolate sections with statuses, remove string concatenation."""
- sections = []
- all_answers = []
- summaries = []
-
- print(f"📊 [BIT-LOG: AGGREGATOR] Merging {len(solutions)} sub-questions")
-
- # 1. Strategy & Visual (V260.0 compat)
- if strategy_card:
- strategy_steps = []
- if "steps" in strategy_card and isinstance(strategy_card["steps"], list):
- for i, s_text in enumerate(strategy_card["steps"]):
- clean_s = sanitize_math_text(s_text)
- strategy_steps.append({"step_id": i+1, "explanation_text": clean_s, "content_mixed": clean_s, "math_artifact": {"type": "equation", "latex": ""}})
- else:
- clean_s = sanitize_math_text(strategy_card.get("content", ""))
- strategy_steps.append({"step_id": 0, "explanation_text": clean_s, "content_mixed": clean_s, "math_artifact": {"type": "equation", "latex": ""}})
-
- sections.append({
- "section_title": strategy_card.get("title", "איך ניגשים לזה? 🧭"),
- "status": "SUCCESS",
- "steps": strategy_steps
- })
-
- if visual_context:
- clean_v = sanitize_math_text(visual_context.get("description", ""))
- sections.append({
- "section_title": visual_context.get("title", "המחשה חזותית ✏️"),
- "status": "SUCCESS",
- "steps": [{"step_id": 0, "explanation_text": clean_v, "content_mixed": clean_v, "math_artifact": {"type": "equation", "latex": visual_context.get("latex_input", "")}}]
- })
-
- for sol in solutions:
- res = sol.get("response", {})
- sub_q_status = "SUCCESS" # Default status
-
- if isinstance(res, dict):
- # V6 Polish: Determine status based on flags in response block
- if res.get("logic_error"):
- # Check if it was a solver failure (no rule found) or parse error. Default to FAILED_RULE if logic error flagged.
- sub_q_status = "FAILED_RULE"
-
- if "sections" in res and res["sections"]:
- # Flatten sub-question sections and append status
- for section in res["sections"]:
- base_title = section.get('section_title', '')
- if "סעיף" not in base_title:
- section["section_title"] = f"סעיף {sol.get('sub_question_id', '?')} - {base_title}"
- section["status"] = sub_q_status
- sections.append(section)
- else:
- # If this sub-question failed entirely and has no steps to show
- sections.append({
- "section_title": f"סעיף {sol.get('sub_question_id', '?')} - הופסק או נכשל",
- "status": sub_q_status,
- "steps": []
- })
-
- if res.get("teacher_summary") and not res.get("logic_error"):
- summaries.append(res["teacher_summary"])
-
- response = build_standard_response(
- sections=sections,
- final_answer="הפתרון לכלל הסעיפים מפורט למטה.",
- teacher_summary=summaries[0] if summaries else "סיימנו את הניתוח.",
- graph_base64=None,
- audio_base64=None,
- logic_error=False,
- response_type="standard",
- strategy_card=strategy_card,
- visual_context=visual_context
- )
-
- print(f"✅ [BIT-LOG: AGGREGATOR] Response built. Sections: {len(sections)}")
- return response
-
-
-
- # ===================== CORE SOLVE =====================
-
- async def solve_problem(self, problem_text, grade, student_name, **kwargs):
- """
- V277.0: Main solve method with BINARY DATA SUPPORT and TUTOR SESSION support.
- """
- uid = kwargs.get('uid')
- session_id = kwargs.get('session_id')
-
- # ===================== V318.0: TUTOR SESSION MODE =====================
- if session_id and uid:
- print(f"🎓 [TUTOR-MODE] Activating Session-Based Dialogue for session: {session_id}")
- # We yield from the tutor handler
- event = await self._handle_tutor_session(problem_text, student_name, uid, session_id, **kwargs)
- yield event
- return
- # ===================== END TUTOR SESSION MODE =====================
- uid = kwargs.get('uid')
- image_data_list = kwargs.get('image_data_list')
- image_data = kwargs.get('image_data') or kwargs.get('image_bytes')
-
- if image_data and not image_data_list:
- image_data_list = [image_data]
- elif image_data_list and not image_data:
- image_data = image_data_list[0]
-
- # V316.0: CRITICAL - Ensure image_data is explicitly passed in kwargs for the rest of parameters
- kwargs['image_data'] = image_data
- kwargs['image_data_list'] = image_data_list
-
- question_id = kwargs.get('question_id', f"q_{int(time.time())}")
- start_time = asyncio.get_event_loop().time()
- # GLOBAL_TIMEOUT_SEC = 240 # 4 minutes usually
-
- # Immediate Heartbeat for UX
- yield BuddyEvent(
- question_id=question_id,
- state=BuddyState.SECTION_WORKING,
- current_section_id="ניתוח תמונה",
- payload={"status": "המורה קוראת את השאלה..."}
- )
-
- # ===================== V285.0: CHECK ME ROUTING =====================
- mode = kwargs.get('mode', 'solve')
- if mode == 'check' and image_data:
- print(f"📝 [V285.0] Mode=CHECK detected. Routing to _check_student_work()...")
- student_gender = kwargs.get('student_gender', 'M')
- async for event in self._check_student_work(
- image_data_list=image_data_list,
- grade=grade,
- student_name=student_name,
- student_gender=student_gender,
- question_id=question_id
- ):
- yield event
- return
- # ===================== END CHECK ME ROUTING =====================
-
- if image_data_list:
- print(f"🔵 [BIT-LOG] Starting OCR Pipeline on {len(image_data_list)} images...")
- ocr_results = []
- for i, img in enumerate(image_data_list):
- print(f"📸 [BIT-LOG] Transcribing image {i}...")
- text = await self.transcribe_image(img)
- if text:
- ocr_results.append(text)
-
- problem_text = "\n\n".join(ocr_results)
- image_data = image_data_list[0] # Use first image for main processing logic/anchors
-
- logger.info(f"🔎 [TRACE] RAW OCR TEXT: {problem_text}")
-
- student_gender = kwargs.get('student_gender', 'M')
-
- print(f"🧠 [BIT-LOG] Orchestrating for {student_name} (V277.0 - BINARY DATA SUPPORT)")
-
- # 🧱 Firewall 1: OCR Early Exit
- import re
- ocr_clean = problem_text.strip() if problem_text else ""
-
- # חייב להכיל לפחות אות אנגלית אחת, מספר, או סימן מתמטי
- has_math_anchor = bool(re.search(r'[0-9xyzXYZ=+\-\(\)]', ocr_clean))
- # V5.7.5: Short Math Bypass (Happy Flow for simple equations)
- # Often simple equations like $2+2=?$ yield low OCR confidence but are valid.
- is_short_math = has_math_anchor and len(ocr_clean) < 15 and len(ocr_clean) > 2
-
- # מצב 3: Hard Stop (Low Confidence)
- if self._last_ocr_confidence < CONFIDENCE_THRESHOLD_MEDIUM and not is_short_math:
- print(f"🔴 [V3.1.2] Hard Stop: Confidence {self._last_ocr_confidence:.2f} < {CONFIDENCE_THRESHOLD_MEDIUM}")
- yield BuddyEvent(
- question_id=question_id,
- state=BuddyState.ERROR,
- payload=build_standard_response(
- final_answer="המערכת לא הצליחה לזהות את כל הנתונים. אנא צלם שוב בצורה ברורה יותר.",
- logic_error=True,
- # response_type="error",
- # error_type="RECAPTURE_REQUIRED"
- )
- )
- return
-
- # מצב 2: Soft Recovery (Medium Confidence)
- ambiguity_warning = self._last_ocr_confidence < CONFIDENCE_THRESHOLD_HIGH
- if ambiguity_warning:
- print(f"🟡 [V3.1.2] Soft Recovery: Confidence {self._last_ocr_confidence:.2f} < {CONFIDENCE_THRESHOLD_HIGH}")
-
- # ===================== V5.8.0: STRATEGY RESOLUTION =====================
- strategy = self._quick_classify(problem_text)
- print(f"🏷️ [BIT-LOG] Strategy: {strategy.value}")
-
- # V8.5: Initialize Token Tracking
- from cost_tracker import CostTracker
- tokens = CostTracker()
-
- # ===================== SIMPLE FAST PATH =====================
- if strategy == ProcessingStrategy.SIMPLE_ARITHMETIC:
- print("⚡ [BIT-LOG] Using SIMPLE_ARITHMETIC Fast Path")
- yield BuddyEvent(question_id=question_id, state=BuddyState.SECTION_WORKING, current_section_id="A", payload={"status": "Solving locally..."})
-
- fast_result = await self._quick_solve(
- problem_text=problem_text, grade=grade, student_name=student_name,
- image_data=image_data, ambiguity_warning=ambiguity_warning
- )
-
- if fast_result:
- fast_result, _, _ = validate_and_fix_solution(fast_result)
- # Quick Polygraph check
- _poly_steps = collect_all_steps(fast_result)
- _poly_ok, _ = await MathPolygraph.validate_step_sequence(_poly_steps, topic=str(strategy.value))
-
- if _poly_ok:
- yield BuddyEvent(
- question_id=question_id,
- state=BuddyState.SECTION_READY,
- current_section_id="A",
- payload=build_standard_response(**fast_result)
- )
- yield BuddyEvent(question_id=question_id, state=BuddyState.COMPLETE, payload={})
- return
-
- # ===================== FULL STREAMING PIPELINE =====================
- print(f"🎯 [BIT-LOG] Using Streaming Pipeline Strategy: {strategy.value}")
-
- # V316.0: image_data is already hydrated at the top of solve_problem.
- # This block is now redundant but kept for safety if someone moves things.
- if image_data is None and image_data_list:
- image_data = image_data_list[0]
- print("📸 [BIT-LOG] Using first image from list for Data Anchor phase. (Redundant Check)")
-
- data_anchor = await self._extract_key_data(problem_text, image_data=image_data) or {}
-
- # V8.9.2: SEPARATE VALIDATOR PASS (Single Source of Truth)
- if image_data and data_anchor:
- print("🛡️ [V8.9.2] Starting Data Anchor Validation Pass...")
- data_anchor = await self._validate_anchor(data_anchor, image_data, problem_text)
-
- # V1.0: GEOMETRIC SANITY CHECK (Ground Truth Injection)
- # Runs BEFORE the LLM to verify algebraic consistency of the extracted data.
- # Injects 'verified_facts' and 'geometry_warnings' into the anchor.
- try:
- _geo_anchor, _geo_prompt_block = run_geometric_sanity(data_anchor)
- if _geo_anchor.verified_facts or _geo_anchor.warnings:
- data_anchor["_verified_facts"] = _geo_anchor.verified_facts
- data_anchor["_geometry_warnings"] = _geo_anchor.warnings
- data_anchor["_geo_prompt_block"] = _geo_prompt_block
- print(f"🔬 [GEO-SANITY] Injected {len(_geo_anchor.verified_facts)} fact(s), "
- f"{len(_geo_anchor.warnings)} warning(s) into data_anchor.")
- except Exception as _geo_err:
- logging.warning(f"[GEO-SANITY] Non-fatal error: {_geo_err}")
-
- # Iterate through the streaming smart_solve
- # V5.10.2: Remove keys already passed explicitly to avoid TypeError collision
- for _key in ['student_gender', 'image_data', 'image_data_list', 'image_bytes',
- 'mode', 'question_id', 'user_note']:
- kwargs.pop(_key, None)
- try:
- async for event in self.smart_solve(
- problem_text=problem_text,
- data_anchor=data_anchor,
- grade=grade,
- category="investigation", # simplified
- student_name=student_name,
- student_gender=student_gender,
- processing_strategy=strategy,
- image_data=image_data,
- image_data_list=image_data_list,
- ambiguity_warning=ambiguity_warning,
- question_id=question_id,
- **kwargs
- ):
- # 🛡️ Global Guard 1: Token Controller
- # Check current tokens from global tracking if possible, or per call
- # (For now we rely on the fact that each LLM call logs to CostTracker)
-
- # 🛡️ Global Guard 2: Timeout
- elapsed = asyncio.get_event_loop().time() - start_time
- if elapsed > GLOBAL_TIMEOUT_SEC:
- print(f"🛑 [V8.5] Global Timeout ({elapsed:.1f}s) reached. Cutting stream.")
- yield BuddyEvent(
- question_id=question_id,
- state=BuddyState.COMPLETE,
- payload={"warning": "Solving timed out. Partial results shown."}
- )
- return
-
- yield event
-
- # Final Event - No longer needed as smart_solve now yields the final COMPLETE event
- # yield BuddyEvent(question_id=question_id, state=BuddyState.COMPLETE, payload={})
- pass
-
- except Exception as e:
- logger.exception("STREAMING ERROR")
- yield BuddyEvent(
- question_id=question_id,
- state=BuddyState.ERROR,
- payload={"error": str(e), "message": "אופס! משהו השתבש בעיבוד השאלה."}
- )
-
-
- # V260.5: General Q&A ("Ask the Teacher")
- async def ask_question(self, context_data, question, student_name):
- """
- Answers a specific student question based on the problem context.
- context_data: The full solution JSON (or relevant parts).
- question: The student's question.
- """
- # Serialize context for prompt
- context_str = json.dumps(context_data, ensure_ascii=False)
-
- prompt = f"""
- תפקיד: מורה פרטית למתמטיקה (המורה למתמטיקה V260.5).
- התלמיד {student_name} שואל שאלה על הפתרון שקיבל.
-
- הקשר (הנתונים והפתרון שכבר נוצר):
- {context_str}
-
- השאלה של התלמיד:
- "{question}"
-
- הנחיות:
- 1. ענה לעניין, בצורה קצרה וממוקדת.
- 2. השתמש בטון מעודד וחם (כמו "הנסיך/הנסיכה").
- 3. אם השאלה קשורה לנוסחה, כתוב אותה ב-LaTeX תקני (בתוך $...$).
- 4. אם התלמיד לא הבין משהו, הסבר לו במילים פשוטות יותר.
- 5. קריטי: אתה בממשק צ'אט נטול קבצים מצורפים! לעולם אל תגיד "ההסבר המלא בפתרון המצורף" או לשלוח את התלמיד למקור חישוב חיצוני. עליך לספק את **כל החישוב וההסבר המתמטי המלא והמפורט** ישירות בתוך הטקסט של התשובה שלך (שדה "answer").
-
- חובה: החזר JSON בלבד!
- {{
- "answer": "התשובה המלאה, כולל כל צעדי החישוב וההסבר המתמטי...",
- "follow_up_suggestion": "שאלה נוספת שהתלמיד יכול לשאול (אופציונלי)"
- }}
- """
-
- try:
- res = await asyncio.wait_for(
- self.model.generate_content_async(prompt),
- timeout=30.0
- )
-
- # Extract JSON
- match = re.search(r'\{.*\}', res.text, re.DOTALL)
- if not match:
- raise ValueError("No JSON found in LLM response for ask_question")
- data = safe_extract_json(match.group(), "ask_question")
-
- # Sanitize math in answer
- if "answer" in data:
- from pedagogical_builder import sanitize_math_text
- data["answer"] = sanitize_math_text(str(data["answer"]))
-
- return build_standard_response(
- teacher_summary=data.get("answer", ""),
- sections=[{
- "section_title": "תשובה לשאלה",
- "steps": [{
- "step_id": 1,
- "explanation_text": data.get("answer", ""),
- "math_artifact": {"type": "equation", "latex": "", "table_data": ""}
- }]
- }],
- final_answer=data.get("follow_up_suggestion", "")
- )
-
- error_msg = "לא הצלחתי להבין את השאלה, נסה לנסח שוב? 🤔"
- return build_standard_response(
- teacher_summary=error_msg,
- logic_error=True
- )
-
- except Exception as e:
- print(f"🔥 [BIT-LOG] Ask Question error: {e}")
- return build_standard_response(
- teacher_summary="אופס, נתקלתי בבעיה קטנה. בוא ננסה שוב! 😅",
- logic_error=True
- )
-
- async def explain_specific_step(self, context, step_text, student_name):
- """V231.4: Step explanation with LaTeX shield."""
- prompt = f"""
- תפקיד: מורה פרטית למתמטיקה (המורה למתמטיקה V231.4).
- התלמיד {student_name} ביקש הסבר נוסף על צעד ספציפי.
-
- ההקשר: {context}
- הצעד שצריך הסבר: {step_text}
-
- הסבר את הצעד מאפס, כאילו התלמיד רואה את הנושא בפעם הראשונה.
- השתמש בפורמט: "הסבר בעברית :: $נוסחה$"
- חובה: כל פקודת LaTeX חייבת להתחיל ב-\\ (למשל: \\frac, \\cdot, \\sqrt).
-
- החזר JSON: {{ "explanation": "...", "example": "..." }}
- """
- try:
- res = await asyncio.wait_for(
- self.model.generate_content_async(prompt),
- timeout=30.0
- )
- match = re.search(r'\{.*\}', res.text, re.DOTALL)
- if not match:
- raise ValueError("No JSON found in LLM response for explain_step")
- data = safe_extract_json(match.group(), "explain_step")
- data = self._scrub_placeholders(data)
- data = self._enhance_latex_v2(data)
- if data and ("explanation" in data or "example" in data):
- return data
- return {"explanation": "לא הצלחתי לייצר הסבר.", "example": ""}
- except Exception as e:
- print(f"🔥 [BIT-LOG] Explain error: {e}")
- return {"explanation": "המורה למתמטיקה נתקל בקושי. נסה שוב.", "example": ""}
-
- async def _handle_tutor_session(self, problem_text, student_name, uid, session_id, **kwargs):
- """
- V318.0: Logic for Phase A - Contextual Memory & Tutor Dialogue.
- """
- question_id = kwargs.get('question_id', f"tutor_{int(time.time())}")
- image_data_list = kwargs.get('image_data_list') or []
- grade = kwargs.get('grade', "כיתה י'")
-
- # 1. Fetch History (Last 10 messages)
- history_docs = firebase_manager.get_chat_history(uid, session_id, limit=10)
-
- # 2. Map history to Gemini format
- history_contents = []
- for doc in history_docs:
- role = "user" if doc.get('role') == 'user' else "model"
- history_contents.append({
- "role": role,
- "parts": [doc.get('content', '')]
- })
-
- # 3. System Instruction
- system_instruction = f"""את מורה פרטית למתמטיקה. המטרה שלך היא לנהל דיאלוג למידה עם התלמיד {student_name} (כיתה {grade}).
-חוקי השיחה:
-1. אם זו תחילת שיחה (היסטוריה ריקה): אל תפתרי כלום. שאלי את התלמיד: 'היי {student_name}! מה אנחנו לומדים היום בכיתה?' וחכי לתשובה.
-2. ברגע שזוהה נושא: שמרי אותו בזיכרון השיחה והתייחס אליו בהמשך.
-3. בבקשת 'בדקי לי' (כשיש תמונות של פתרון): נתחי את התמונה מול השאלה. אם יש טעות, צייני את השורה ואת סוג הטעות (סימנים, חוקי חזקות וכו'). אל תתקני מיד, תני רמז.
-4. השתמש בטון מעודד, חם ובגובה העיניים.
-5. כל פלט חייב להיות בפורמט JSON תקין לפי הסכימה המוגדרת.
-"""
-
- # 4. Construct Current Input
- current_input_parts = []
- if problem_text:
- current_input_parts.append(problem_text)
-
- for img_bytes in image_data_list:
- current_input_parts.append({
- "mime_type": "image/jpeg",
- "data": img_bytes
- })
-
- if not current_input_parts:
- current_input_parts.append("(התלמיד הצטרף לשיחה)")
-
- # 5. Gemini Call with Structured Output
- try:
- # We use a temporary chat session to include history
- chat = self.model.start_chat(history=history_contents)
-
- # V318: Enforce JSON mode and schema
- generation_config = genai.GenerationConfig(
- response_mime_type="application/json",
- response_schema=TutorResponseSchema,
- temperature=0.7
- )
-
- # Prepend system instruction as a message if model doesn't support separate system_instruction in start_chat
- # Actually, Gemini 2.0 Flash supports system_instruction in the model constructor or in GenerateContent.
- # Here we'll append it to the prompt for maximum compatibility with existing self.model.
- full_prompt = f"[SYSTEM_INSTRUCTION]\n{system_instruction}\n\n[USER_INPUT]\n"
-
- res = await self.model.generate_content_async(
- contents=history_contents + [{"role": "user", "parts": [full_prompt] + current_input_parts}],
- generation_config=generation_config
- )
-
- # 6. Parse & Save
- data = json.loads(res.text)
- student_message = data.get("student_message", "")
- analytics = data.get("internal_analytics", {})
-
- # Determine intent for metadata
- intent = analytics.get("intent", "CHAT")
- mastery_score = analytics.get("mastery_score", 0)
- topic = analytics.get("topic", "General")
-
- # V318.0: TRIGGER CHALLENGE GENERATOR if Mastery > 70
- if mastery_score > 70 and intent in ["SOLVE", "CHECK"]:
- logger.info(f"🏆 [TUTOR-MODE] High Mastery detected ({mastery_score}). Triggering Challenge Generator.")
- from exercise_generator import exercise_generator
- # Fire and forget (Background Task)
- asyncio.create_task(exercise_generator.generate_challenge(problem_text or "(מעבד תמונה)", topic, uid))
-
- # Append the challenge offer to the tutor's message
- offer_text = "\n\nכל הכבוד! הכנתי לך תרגיל אתגר דומה כדי לוודא שזה יושב טוב. רוצה לנסות? 💪"
- student_message += offer_text
-
- # Save User Message (if there was text/images)
- if problem_text or image_data_list:
- firebase_manager.save_chat_message(
- uid, session_id, "user", problem_text or "(תמונה)",
- metadata={"intent": intent}
- )
-
- # Save Assistant Message
- firebase_manager.save_chat_message(
- uid, session_id, "assistant", student_message,
- metadata=analytics
- )
-
- # 7. Map to BuddyEvent for UI
- return BuddyEvent(
- question_id=question_id,
- state=BuddyState.COMPLETE,
- payload=build_standard_response(
- teacher_summary=student_message,
- final_answer=""
- )
- )
-
- except Exception as e:
- logger.error(f"❌ [TUTOR-SESSION] Error: {e}")
- return BuddyEvent(
- question_id=question_id,
- state=BuddyState.ERROR,
- payload={"error": str(e), "message": "המורה נתקלה בבעיה בזיכרון של השיחה."}
- )
-
- # ===================== PIPELINE METHODS =====================
-
- def _get_v231_pedagogic_block(self):
- """V231.4: Pedagogic instructions appended to every prompt."""
- return r"""
- \n🛑 משימה: המורה למתמטיקה (סטנדרט פרימיום V231.4) 🛑
- 1. הסבר מאפס: כל שלב מלווה בהסבר מילולי עשיר.
- 2. בטיחות לטך: חובה להשתמש ב- \\frac{}{} ולא ב- frac.
- 3. ניקיון: אל תשאיר פלייסהולדרים כמו $formula$ או $math$ בטקסט.
- 4. כל פקודת LaTeX חייבת להתחיל ב-\\. למשל: \\frac, \\cdot, \\sqrt, \\pi.
- 5. Hebrew or GEOMETRIC NOTATION (e.g., \angle, ^\circ, \triangle) inside $$ is FORBIDDEN. It breaks the app. Use inline math in content_mixed for dimensions/angles.
- 6. NEVER write \\left or \\right. Use ( ) instead.
- 7. Each math expression MUST be on its OWN LINE.
- 8. NEVER use \\ne (conflicts with newline). USE \\neq instead.
- 9. STRATEGY CARD: The first section "איך ניגשים לזה?" must contain ONLY hints and thinking strategy — NO final answers!
- 10. OCR SACRED: You MUST solve the EXACT function from the image. If OCR says x^2/(x^2-4), solve THAT. Do NOT invent a different function.
- 11. MATH SEPARATION (CRITICAL): block_math is for PURE ALGEBRA ONLY. NO \angle, \triangle, ^\circ, \text{}, \quad, or 'and' allowed in block_math. Use inline math `$...$` in content_mixed for dimensions, labels, or explanatory math.
- 12. PEDAGOGICAL HIGHLIGHTING: When performing a substitution, showing a change in sign, taking a derivative, or highlighting a key transition in a calculation, use `\color{color_name}{...}` (e.g., `\color{red}{x^2}`, `\color{blue}{+4}`) to visually highlight the element that changed or needs the student's focus. Ensure you only wrap valid Math in the color tag.
- """
-
- def _scrub_placeholders(self, data):
- """✅ V231.0: מסיר פלייסהולדרים ריקים ש-Gemini משאיר בטעות"""
- if isinstance(data, str):
- result = data
- result = re.sub(r'\$\s*formula\d*\s*\$', '', result)
- result = re.sub(r'\$\s*math\d*\s*\$', '', result)
- result = result.replace('[math]', '')
- result = result.replace('[formula]', '')
- result = re.sub(r'::\s*$', '', result, flags=re.MULTILINE)
- return result.strip()
- if isinstance(data, dict):
- return {k: self._scrub_placeholders(v) for k, v in data.items()}
- if isinstance(data, list):
- return [self._scrub_placeholders(i) for i in data]
- return data
-
- def _enhance_latex_v2(self, data):
- """✅ V231.2: מתקן לוכסנים רק בתוך $...$ — לא נוגע בעברית!"""
- if isinstance(data, str):
- def _fix_content(content):
- content = re.sub(
- r'(? 0:
- first_sec = sections[0]
- title = first_sec.get('section_title', '')
- if 'ניגשים' in title or 'אסטרטגיה' in title or '🧭' in title:
- # This is the strategy card — scrub Hebrew side
- steps = first_sec.get('steps', [])
- for step in steps:
- if 'content_mixed' in step:
- val = step['content_mixed']
- if '::' in val and '$' in val:
- parts = val.split('::', 1)
- hebrew = re.sub(r'\$[^$]*\$', '', parts[0]).strip()
- step['content_mixed'] = f"{hebrew} :: {parts[1].strip()}"
- else:
- # Pure Hebrew strategy line — strip any accidental LaTeX
- val = re.sub(r'\\[a-zA-Z]+', '', val)
- val = val.replace('$', '').replace('{', '').replace('}', '')
- step['content_mixed'] = re.sub(r'\s+', ' ', val).strip()
- return data
- return data
-
- def _inject_bidi_markers(self, data):
- """✅ V231.11: Smart BiDi with RLM markers (Right-to-Left Mark)."""
- # RLM (\u200F) marks direction without reversing text
- # Unlike RLE (\u202B) which was causing full text reversal
-
- if isinstance(data, str):
- # Check if string contains Hebrew
- has_hebrew = bool(re.search(r'[\u0590-\u05FF]', data))
-
- if has_hebrew:
- return '\u200F' + data
-
- return data
-
- if isinstance(data, dict):
- return {k: self._inject_bidi_markers(v) for k, v in data.items()}
- if isinstance(data, list):
- return [self._inject_bidi_markers(i) for i in data]
- return data
-
- def _purge_double_dollars(self, data):
- """✅ V275.3: Remove orphan double-dollars, fix quadruple dollars, and unwrap Hebrew-in-math."""
- if isinstance(data, str):
- # Fix quadruple dollars $$$$ -> $$ globally before processing
- data = re.sub(r'\${3,}', '$$', data)
-
- # Remove empty math blocks: $$ $$ or $$$$
- data = re.sub(r'\$\$\s*\$\$', '', data)
-
- # V275.4: Unwrap Hebrew paragraphs from $$...$$ blocks (mirror text fix)
- def _unwrap_hebrew(match):
- content = match.group(1).strip()
- hebrew_chars = len(re.findall(r'[\u0590-\u05FF]', content))
- total_chars = len(content.replace(' ', ''))
- if total_chars == 0:
- return ''
- # Heuristic 1: >25% Hebrew = text paragraph (lowered from 40%)
- if hebrew_chars / total_chars > 0.25 and hebrew_chars > 8:
- return content
- # Heuristic 2: 3+ consecutive Hebrew words
- if re.search(r'[\u0590-\u05FF]+\s+[\u0590-\u05FF]+\s+[\u0590-\u05FF]+', content):
- return content
- return match.group(0)
-
- data = re.sub(r'\$\$(.+?)\$\$', _unwrap_hebrew, data, flags=re.DOTALL)
-
- # Preserve display math: $$...$$
- # Remove orphan $$: "text $$" or "$$ text"
-
- # Strategy: Protect display math blocks, purge orphans, restore protected
- protected = []
-
- def protect_display_math(match):
- protected.append(match.group(0))
- return f"__DISPLAY_MATH_{len(protected)-1}__"
-
- # Protect $$...$$ blocks (non-greedy match)
- result = re.sub(r'\$\$[^$]+?\$\$', protect_display_math, data)
-
- # Now purge orphan $$ (multiple consecutive $)
- result = re.sub(r'\$\$+', '$', result)
-
- # Restore protected blocks
- for i, block in enumerate(protected):
- result = result.replace(f"__DISPLAY_MATH_{i}__", block)
-
- if result != data:
- print(f"💲 [BIT-LOG] Purged orphan $$ and fixed quadruple $: '{data[:50]}...' → '{result[:50]}...'")
- return result
- if isinstance(data, dict):
- return {k: self._purge_double_dollars(v) for k, v in data.items()}
- if isinstance(data, list):
- return [self._purge_double_dollars(i) for i in data]
- return data
-
- def _error_response(self):
- return build_standard_response(
- final_answer="המורה למתמטיקה נתקל בקושי. נסה שוב.",
- teacher_summary="המורה למתמטיקה מתנצל, אך חלה שגיאה לא צפויה.",
- logic_error=True,
- response_type="error"
- )
-
- def _sanitize_for_sympy(self, expr: str) -> str:
- """✅ V231.4: Robust SymPy sanitizer.
- Converts \\frac{4}{3}x -> (4)/(3)*x, handles all implicit multiplication."""
- s = str(expr)
-
- # Step 0: Pre-algebraic cleanup (Remove UI/Geometric/Text notation)
- s = re.sub(r'\\color\{.*?\}(?:\{.*?\})?', '', s) # Aggressively strip color tags
- s = re.sub(r'\\text\{[^{}]*\}', '', s)
- s = re.sub(r'\^(\{\\circ\}|\\circ)', '', s)
- s = re.sub(r'\\(angle|triangle|quad|qquad)', '', s)
- s = re.sub(r'\band\b', '', s)
- s = re.sub(r'\s*,\s*', ' ', s) # Remove commas with surrounding space
-
- # Step 1: Convert LaTeX fractions PROPERLY: \frac{a}{b} -> (a)/(b)
- while '\\frac' in s:
- s = re.sub(r'\\frac\s*\{([^{}]*)\}\{([^{}]*)\}', r'(\1)/(\2)', s)
- if '\\frac' in s and '{' not in s:
- s = s.replace('\\frac', '')
-
- # Step 2: Convert other LaTeX commands
- s = s.replace('\\cdot', '*').replace('\\times', '*')
- s = s.replace('\\pi', 'pi').replace('\\sqrt', 'sqrt')
- s = s.replace('\\sin', 'sin').replace('\\cos', 'cos').replace('\\tan', 'tan')
- s = s.replace('\\ln', 'ln').replace('\\log', 'log')
- s = s.replace('\\left', '').replace('\\right', '')
-
- # Step 3: Convert remaining braces to parens
- s = s.replace('{', '(').replace('}', ')')
-
- # Step 4: Convert ^ to **
- s = s.replace('^', '**')
-
- # Step 5: Implicit multiplication (run multiple passes)
- for _ in range(3): # Multiple passes catch nested cases
- s = re.sub(r'(\d)([a-zA-Z(])', r'\1*\2', s) # 4x → 4*x, 2( → 2*(
- s = re.sub(r'\)([a-zA-Z0-9(])', r')*\1', s) # )x → )*x, )( → )*(
- s = re.sub(r'([a-zA-Z])(? str:
- """V281.1: Aggressively strips non-printable characters from math blocks."""
- if not text: return ""
- # Remove Tabs, Newlines, and multiple spaces which break KaTeX
- s = text.replace('\t', ' ').replace('\n', ' ').replace('\r', ' ')
- s = re.sub(r'\s+', ' ', s)
- return s.strip()
-
- async def _validate_anchor(self, data_anchor: dict, image_data: bytes, problem_text: str = "") -> dict:
- """V8.9.2: Single Source of Truth Validator pass."""
- try:
- from prompts import get_anchor_validation_prompt
- from utils.safe_json import safe_extract_json
-
- prompt = get_anchor_validation_prompt(data_anchor)
-
- # Using current model which supports Vision
- response = await self.model.generate_content_async(
- [
- {"mime_type": "image/jpeg", "data": image_data},
- prompt
- ]
- )
-
- match = re.search(r'\{.*\}', response.text, re.DOTALL)
- if match:
- clean_anchor = safe_extract_json(match.group(), "anchor_validator")
- if clean_anchor:
- print(f"🛡️ ✅ [V8.9.2] Anchor Validated: {len(clean_anchor.get('function_equations', []))} equations found.")
- return clean_anchor
-
- # V8.9.6: If validator returns garbage, Fallback to raw OCR
- print("⚠️ [V8.9.6] Validator returned invalid JSON. Falling back to raw OCR.")
- return {"raw_ocr_text": problem_text}
- except Exception as e:
- print(f"⚠️ [V8.9.2] Anchor Validation failed: {e}. Falling back to raw OCR.")
- return {"raw_ocr_text": problem_text}
-
- async def _save_exercise_history(self, uid: str, question: str, solutions: list):
- """V317.0: Saves sanitized exercise history with clean titles."""
- try:
- db = firebase_manager.get_db()
- if not db or not uid: return
-
- def generate_clean_title(ocr_raw_text):
- try:
- # מנקה JSON אם קיים
- if isinstance(ocr_raw_text, str) and ocr_raw_text.strip().startswith('{'):
- match = re.search(r'\{.*\}', ocr_raw_text, re.DOTALL)
- if match:
- data = safe_json_loads(match.group())
- text = data.get('text', '')
- else:
- text = str(ocr_raw_text)
- else:
- text = str(ocr_raw_text)
-
- # ניקוי LaTeX וסימנים טכניים מתקדם
- # 1. הסרת בלוקים של מתמטיקה $...$
- text = re.sub(r'\$.*?\$', '', text)
- # 2. הסרת פקודות LaTeX נפוצות (למשל \frac{...}{...})
- text = re.sub(r'\\[a-zA-Z]+', '', text)
- # 3. הסרת סוגריים מסולסלים ומרובעים
- text = re.sub(r'[\{\}\[\]]', '', text)
- # 4. ניקוי סימנים מתמטיים שאריתיים
- text = re.sub(r'[\^_*=+\-/|]', '', text)
-
- # חיתוך ל-6 מילים ראשונות
- words = text.split()
- if not words: return "תרגיל במתמטיקה"
- title = " ".join(words[:6])
- if len(words) > 6: title += "..."
- return title.replace('\n', ' ').strip()
- except Exception as e:
- logging.debug(f"⚠️ [BIT-LOG] Title generation failed: {e}")
- return "תרגיל במתמטיקה"
-
- # Flatten solution steps into a single string
- solution_text_parts = []
- for sol in solutions:
- res = sol.get("response", {})
- if "sections" in res:
- for section in res["sections"]:
- title = section.get("section_title", "")
- solution_text_parts.append(f"### {title}")
- for step in section.get("steps", []):
- exp = step.get("explanation_text", "") or ""
- math = step.get("math_artifact", {}).get("latex", "") or ""
- if not math: math = step.get("block_math", "") or ""
-
- solution_text_parts.append(str(exp))
- if math:
- math = self._deep_sanitize_math(str(math))
- solution_text_parts.append(f"$${math}$$")
- solution_text_parts.append("---")
-
- full_solution = "\n\n".join(solution_text_parts)
-
- from firebase_admin import firestore
- import datetime
- # Save to history collection with clean title
- history_ref = db.collection('users').document(uid).collection('history').document()
- history_ref.set({
- "original_question_text": question,
- "display_title": generate_clean_title(question),
- "solution_steps_text": full_solution,
- "timestamp": firestore.SERVER_TIMESTAMP
- })
- print(f"✅ [HISTORY] Successfully saved exercise to DB: users/{uid}/history")
- except Exception as e:
- print(f"❌ [HISTORY] Error saving history: {e}")
- import traceback
- traceback.print_exc()
-
- # ===================== EXISTING PIPELINE METHODS =====================
-
- def _smart_minify(self, data):
- """V226.1+ Line-based minifier that preserves newlines around Math blocks."""
- if isinstance(data, str):
- clean = data.replace('\r\n', '\n').replace('\r', '\n')
- token = "###NEWLINE_TOKEN###"
- clean = re.sub(r'\n\s*\n', token, clean)
- lines = clean.split('\n')
- result = []
- for i, line in enumerate(lines):
- stripped = line.strip()
- if not stripped: continue
- should_keep = False
- if stripped.endswith('$$') or stripped.startswith('$$'): should_keep = True
- if i + 1 < len(lines):
- next_stripped = lines[i+1].strip()
- if next_stripped.startswith('$$') or next_stripped.startswith('*') or next_stripped.startswith('-'): should_keep = True
- if next_stripped.startswith('**'): should_keep = True
- result.append(stripped)
- if should_keep: result.append('\n')
- else: result.append(' ')
- final_text = "".join(result).replace(token, '\n\n')
- final_text = re.sub(r' +', ' ', final_text)
- return final_text.strip()
- elif isinstance(data, dict):
- return {k: self._smart_minify(v) for k, v in data.items() if v is not None}
- elif isinstance(data, list):
- return [self._smart_minify(i) for i in data if i is not None]
- return data
-
- def _sanitize_teacher_response(self, text: str) -> str:
- """
- V275.2: ANTI-CHATTER GUARD 🛡️
- Removes ENTIRE SENTENCES containing apologetic/self-correction language.
- V261.7: PIPELINE SYNC - Now also runs sanitize_math_text!
- """
- if not text: return ""
-
- # V275.4: Expanded chatter phrases - catches self-correction monologue from LLM
- chatter_words = (
- r"(oops|sorry|mistake|apologize|let me correct|my bad"
- r"|אופס|סליחה|טעות|מתנצל|תיקון|שגיתי|טעיתי|מצטער"
- r"|בוא נתקן|רגע|שים לב לטעות|הייתה טעות|נפלה טעות|תיקון טעות|בחישוב הקודם"
- # V275.4: New patterns from investigation question logs
- r"|טעות חשיבה|בוא ננסה שוב|סוף סוף הבנתי|על הבלבול"
- r"|זה לא עובד|מה עושים|זה לא טוב|זה לא נכון|מה קורה פה"
- r"|בוא נחשוב על זה|שלי הייתה|בוא נחזור אחורה|בוא נתחיל מהתחלה"
- r"|אני קצת מבולבל|על כל הטעויות|נוספת|קונספטואלית|בשאלה"
- r"|wait|רגע|let me think|hold on)"
- )
-
- # Regex to match the ENTIRE SENTENCE containing one of the chatter phrases
- # Matches from previous period/newline to the next period/newline
- chatter_sentence_pattern = r"(?i)\s*[^.!?\n]*?(?:" + chatter_words + r")[^.!?\n]*?[.!?]?"
-
- cleaned = text
- cleaned = re.sub(chatter_sentence_pattern, "", cleaned)
-
- # V261.7: Global Math Sanitization (Fixes mangled ( x )^2 etc.)
- cleaned = sanitize_math_text(cleaned)
-
- return cleaned.strip()
-
- def _scrub_latex_from_text(self, data):
- """✅ V261.3: Aggressively scrub LaTeX commands from Hebrew text."""
- if isinstance(data, str):
- # 1. Protect math blocks $...$ and $$...$$
- protected = []
- def protect(match):
- protected.append(match.group(0))
- return f"__MATH_BLOCK_{len(protected)-1}__"
-
- temp = re.sub(r'\$\$[^$]+?\$\$', protect, data)
- temp = re.sub(r'(? dict:
- """
- V4.2 (Behavioral Firewall): Projection-Only Builder.
- The UI serves ONLY as a projection of the mathematical ProofGraph.
- LLM math generation is strictly forbidden.
- """
- try:
- print(f"🧱 [V4.2] Projection-Only Mode: topic={topic_id}, ProofGraph={proof_graph is not None}")
- print(f"DEBUG [PRE-SCRUB]: LLM generated raw narrative: {llm_output}")
-
- if not proof_graph or not proof_graph.steps:
- # V5.8.0: Enforce Intent Matrix! If strategy is STRICT_SYMBOLIC, failure to provide graph is a fatal error.
- if processing_strategy == ProcessingStrategy.STRICT_SYMBOLIC:
- print(f"🛑 [V5.8.0] STRICT_SYMBOLIC Violation: No ProofGraph provided. Blocking response.")
- raise LLMSchemaError("Truth Authority Violation: STRICT_SYMBOLIC strategy requires a verified ProofGraph.")
-
- if processing_strategy == ProcessingStrategy.HEURISTIC_DEDUCTION:
- print(f"✅ [V7.3] HEURISTIC_DEDUCTION detected. Bypassing Truth Authority.")
- return _build_generic_response(llm_output, custom_title=custom_title)
-
- if isinstance(llm_output, list) and len(llm_output) > 0:
- print(f"✅ [V7.3] Hybrid Navigation detected (List Segment). Bypassing ProofGraph requirement.")
- return _build_generic_response(llm_output, custom_title=custom_title)
-
- if isinstance(llm_output, dict) and ("solution_markdown" in llm_output or "steps" in llm_output or "chain_of_thought" in llm_output):
- return _build_generic_response(llm_output, custom_title=custom_title)
-
- # V8.5 RESILIENCE: One more attempt to find steps if we're failing
- if isinstance(llm_output, dict) and "sections" in llm_output:
- return _build_generic_response(llm_output, custom_title=custom_title)
-
- # If no clues at all, THEN we raise
- logger.warning(f"⚠️ [V8.5] Truth Authority Violation: Falling back to generic due to invalid structure: {llm_output}")
- return _build_generic_response(llm_output, custom_title=custom_title)
-
- # 1. Map ProofGraph to Immutable Truth Nodes
- sympy_nodes = []
- for step in proof_graph.steps:
- sympy_nodes.append({
- "step_id": step.step_id,
- "block_math": step.math_content,
- "title": step.logic_description or f"שלב {step.step_id}"
- })
-
- # 2. Extract explanations from LLM (The "Skin") - V4.2.7 supports list or dict
- if isinstance(llm_output, list):
- llm_explanations = llm_output
- else:
- # V5.8.2: Support parsing nested 'sections' from the LLM output
- llm_explanations = llm_output.get("steps_explanations", llm_output.get("steps", []))
- if not llm_explanations and "sections" in llm_output:
- for section in llm_output["sections"]:
- if "steps" in section:
- llm_explanations.extend(section["steps"])
-
- if not llm_explanations:
- # Internal Fallback: If LLM failed, use generic text to preserve UI
- llm_explanations = [{"step_id": s["step_id"], "explanation_text": "נבצע את החישוב המתמטי"} for s in sympy_nodes]
- else:
- # V276.1: Normalize explanations to handle structured content/type keys
- for node in llm_explanations:
- if "explanation_text" not in node or not node["explanation_text"]:
- node["explanation_text"] = node.get("content_mixed", node.get("content", node.get("explanation", "")))
-
- # Unpack dict if still found
- if isinstance(node["explanation_text"], dict):
- node["explanation_text"] = node["explanation_text"].get("content", node["explanation_text"].get("text", str(node["explanation_text"])))
-
- # V5.8.2: Layer 2 Runtime Validator (The Kill Switch)
- for node in llm_explanations:
- text = node.get("explanation_text", "")
- if not validate_narrative_density(text):
- print(f"🛑 [V5.8.2] KILL SWITCH TRIGGERED on text: {text}")
- raise LLMSchemaError("NARRATIVE_OVERFLOW: Explanation is too dense or contains forbidden math/English characters.")
-
- # 3. Deterministic Merge (Iron Law) - V4.2.7: explanation_text
- merged_steps = merge_and_verify_explanations(sympy_nodes, llm_explanations)
-
- # 5. UI Projection (Hard Decoupling V4.2.10)
- ui_steps = []
- for i, node in enumerate(merged_steps):
- # V8.6.2: Ensure LaTeX preserved in content_mixed (removed aggressive $ and \ stripping)
- explanation = sanitize_math_text(node["explanation_text"])
-
- math_content = node["block_math"]
-
- ui_steps.append({
- "step_id": node["step_id"],
- "step_number": i + 1,
- "explanation_text": explanation,
- "math_artifact": {
- "type": "equation",
- "latex": math_content,
- "table_data": ""
- },
- # We keep these for one more version as 'Ghost Keys' for extreme backward compatibility
- # but they now mirror the structured data perfectly.
- "content_mixed": explanation,
- "block_math": math_content
- })
-
- # V8.6: Inject 'approach' as Step 0 to ensure Flutter displays it
- approach = llm_output.get("approach")
- if approach and isinstance(approach, str):
- ui_steps.insert(0, {
- "step_id": 0,
- "step_number": 0,
- "title": "איך ניגשים לזה? 🧭",
- "explanation_text": sanitize_math_text(approach),
- "content_mixed": sanitize_math_text(approach),
- "math_artifact": {"type": "equation", "latex": ""},
- "block_math": ""
- })
-
- # V8.6.2: Final check on teacher_summary from LLM
- summary = llm_output.get("teacher_summary") or llm_output.get("summary")
-
- response = {
- "sections": [{
- "section_title": custom_title or "פתרון מלא ומדויק",
- "steps": ui_steps,
- "section_result": merged_steps[-1]["block_math"] if merged_steps else ""
- }],
- "final_answer": merged_steps[-1]["block_math"] if merged_steps else "",
- "teacher_closing": llm_output.get("teacher_closing", "כל הכבוד על פתרון התרגיל! 🎉"),
- "approach": approach,
- "teacher_summary": summary
- }
-
- # V260.5: Propagate Investigation Data (Crucial for Table UI)
- if "investigation" in llm_output:
- response["investigation"] = llm_output["investigation"]
- elif "investigation_table" in llm_output:
- response["investigation"] = llm_output["investigation_table"]
-
- return apply_cognitive_load_limiter(response)
- except Exception as e:
- logger.error(f"🚨 [V8.5 RESILIENCE] Builder Crash: {e}. Falling back to generic.")
- return _build_generic_response(llm_output, custom_title=custom_title)
-
-def apply_cognitive_load_limiter(response: dict) -> dict:
- """
- V1.1: Cognitive Load Limiter.
- Ensures steps are revealed gradually.
- """
- if "sections" not in response: return response
-
- # Limit to first 2 steps if complex, mark others as 'hidden'
- step_count = 0
- for section in response["sections"]:
- if "steps" in section:
- for step in section["steps"]:
- step_count += 1
- # V1.1 Rule: If more than 3 steps, flag the rest for gradual disclosure
- if step_count > 3:
- step["disclosure_state"] = "HIDDEN"
- else:
- step["disclosure_state"] = "VISIBLE"
-
- return response
-
-def validate_narrative_density(text: str) -> bool:
- """
- V5.8.2: Layer 2 Runtime Validator (Kill Switch).
- Checks if the pedagogical explanation adheres to the Hard Doctrine.
-
- V8.5: RESILIENCE - Relaxed to allow math symbols in text-only steps.
- Returns False ONLY if it is too long (runaway LLM) or contains dangerous code.
- """
- if len(text) > 400:
- return False
- # V8.5: Increased tolerance for English letters (ABC labels) and math signs.
- # We only block forbidden programmatic keywords like 'def', 'class', etc.
- import re
- if re.search(r'\b(import|def|class|lambda)\b', text):
- return False
- return True
-
-def merge_and_verify_explanations(sympy_nodes: list[dict], llm_explanations: list[dict]) -> list[dict]:
- """
- V2.5.3: The Swiss Watch Maneuver.
- V3.1.3: Hardened Merge Phase with robust guards.
- V5.8.2: Robust Merge (Option 1) to ignore LLM self-referencing narrative drift.
- Merges Immutable SymPy math (Truth) with LLM explanations (Skin).
- """
- final_nodes = [] # V3.1.3: Mandatory initialization
-
- try:
- # V5.8.2 Robust Merge
- for step in sympy_nodes:
- sid = step["step_id"]
-
- # Find all LLM explanations for this step
- candidates = [
- s for s in llm_explanations
- if s.get("step_id") == sid and "explanation_text" in s
- ]
-
- if not candidates:
- # If LLM completely missed a step, fallback to generic
- final_nodes.append({
- **step,
- "explanation_text": "נבצע את החישוב המתמטי."
- })
- continue
-
- # Take the last candidate to ignore preamble/meta-commentary drift
- best_candidate = candidates[-1]
- explanation_text = best_candidate["explanation_text"]
-
- # V6 Narrative Drift Telemetry
- if "allowed_concepts" in step and step["allowed_concepts"]:
- import re
- words = [w for w in re.split(r'\s+', explanation_text) if len(w) > 2] # simple word split ignoring short connectives
- allowed_words = set()
- for concept in step["allowed_concepts"]:
- allowed_words.update(concept.split())
-
- unauthorized_words = [w for w in words if w not in allowed_words]
- drift_percentage = (len(unauthorized_words) / max(len(words), 1)) * 100
-
- if drift_percentage > 50.0:
- import logging
- logger = logging.getLogger(__name__)
- logger.warning(f"⚠️ [V6 TELEMETRY] Drift Warning: {drift_percentage:.1f}% concept drift in Step {sid} (unauthorized: {unauthorized_words[:3]}...)")
-
- # V5.8.2 Kill Switch Validator Call
- if not validate_narrative_density(explanation_text):
- msg = f"NARRATIVE_OVERFLOW: Explanation rejected by Kill Switch: {explanation_text[:20]}..."
- print(f"🚨 [V5.8.2] {msg}")
- raise LLMSchemaError("NARRATIVE_OVERFLOW")
-
- merged_node = {
- **step,
- "explanation_text": explanation_text
- }
- final_nodes.append(merged_node)
-
- return final_nodes
-
- except LLMSchemaError:
- raise
- except Exception as e:
- import logging
- logger = logging.getLogger(__name__)
- logger.error(f"🚨 [V3.1.3] Merge Phase Failed: {e}")
- # Since orchestrator is downstream, we re-raise or return something that indicates failure.
- raise LLMSchemaError(f"Merge failure: {str(e)}")
-
-def _normalize_llm_keys(llm_output: dict) -> dict:
- """V275.3: Map alternative LLM output keys to expected template keys.
- E.g., CIRCLE_EQUATION returns 'equation' but GENERAL template expects 'solution'."""
- result = dict(llm_output)
- # Map equation -> solution if solution is missing
- if "solution" not in result and "equation" in result:
- result["solution"] = result["equation"]
- # Map approach alternatives
- if "approach" not in result and "strategy" in result:
- result["approach"] = result["strategy"]
- return result
-
-
-def _build_template_response(topic_id: str, llm_output: dict, data_anchor: dict) -> dict:
- """Build response using topic-specific template."""
-
- template = PEDAGOGICAL_TEMPLATES[topic_id]
-
- # V275.3: Normalize LLM keys so templates always find what they need
- llm_output = _normalize_llm_keys(llm_output)
-
- # Build response
- section_data = {
- "section_title": "פתרון מלא",
- "steps": [],
- "section_result": llm_output.get("equation") or llm_output.get("solution") or llm_output.get("derivative") # V262.0: Per-section result
- }
-
- response = {
- "sections": [section_data],
- "final_answer": section_data["section_result"],
- "teacher_closing": template.get("closing", "כל הכבוד! 🎉"),
- "teacher_summary": llm_output.get("teacher_summary") # V262.2: Propagate explicit summary
- }
-
- # Add intro step
- if "intro" in template:
- intro = template["intro"]
- response["sections"][0]["steps"].append({
- "step_number": 0,
- "title": intro["title"],
- "content_mixed": intro["content"],
- "teacher_tip": intro.get("tip", "")
- })
-
- # Add main steps
- for i, step_template in enumerate(template["steps"], start=1):
-
- # V275.2 FIX: Handle unpacking of 'steps' list from llm_output gracefully!
- if step_template.get("uses_llm") == "steps" and isinstance(llm_output.get("steps"), list):
- for s in llm_output["steps"]:
- steps_count = len(response["sections"][0]["steps"])
- # V275.3: Check multiple content key names (different micro-prompts use different schemas)
- content = s.get("content_mixed", s.get("content", s.get("explanation", "")))
- new_step = {
- "step_number": steps_count + 1,
- "title": s.get("title", f"שלב {steps_count + 1}"),
- "content_mixed": sanitize_math_text(content),
- "block_math": s.get("block_math", s.get("result", "")),
- "teacher_tip": s.get("teacher_tip", step_template.get("tip"))
- }
- response["sections"][0]["steps"].append(new_step)
- continue
-
- step = {
- "step_number": len(response["sections"][0]["steps"]) + 1,
- "title": step_template["title"]
- }
-
- # Fill content
- if "template" in step_template:
- # Template with data substitution (merge with llm_output to prevent KeyError)
- try:
- content = step_template["template"].format(**{**data_anchor, **llm_output})
- except KeyError:
- # V275.3: Strip unresolved {variable} placeholders instead of showing them raw
- content = re.sub(r'\{\w+\}', '', step_template["template"]).strip()
- step["content_mixed"] = content
-
- if "content" in step_template:
- step["content_mixed"] = step_template["content"]
-
- if "block_math" in step_template:
- step["block_math"] = step_template["block_math"]
-
- if "uses_llm" in step_template:
- # Use LLM output
- llm_key = step_template["uses_llm"]
- if llm_key in llm_output:
- if llm_key == "solution" or llm_key == "approach":
- # Solutions and approaches usually have Hebrew, put them in content to prevent flutter crash
- # If there's already template content, append to it
- if step.get("content_mixed"):
- step["content_mixed"] += "\n" + sanitize_math_text(str(llm_output[llm_key]))
- else:
- step["content_mixed"] = sanitize_math_text(str(llm_output[llm_key]))
- else:
- step["block_math"] = sanitize_math_text(str(llm_output[llm_key]))
-
- if "tip" in step_template:
- step["teacher_tip"] = step_template["tip"]
-
- response["sections"][0]["steps"].append(step)
-
- return response
-
-
-
-import gibberish_detector # V231.25: Fix gibberish!
-
-def sanitize_math_text(text: str) -> str:
- """
- V231.23: Remove English math artifacts and enforce Hebrew/Latex conventions.
- Forces 'Angle' -> '\\angle', 'Triangle' -> '\\triangle', 'Area' -> 'S'.
- V260.4: Also runs auto_fix_gibberish (reversed Hebrew, broken Latex).
- """
- if not text:
- return text
-
- # V260.4: First, fix structural gibberish (reversed Hebrew, broken LaTeX)
- text = gibberish_detector.auto_fix_gibberish(text)
-
- # V275.3: Fix quadruple dollars $$$$ -> $$ EARLY (before any block processing)
- text = re.sub(r'\${3,}', '$$', text)
-
- # V275.4: CRITICAL - Detect and unwrap $$Hebrew paragraph$$ blocks
- # The LLM wraps Hebrew explanations in $$...$$ which renders as garbled "mirror text"
- # Key insight: Hebrew text mixed with g(x), \ln(x) etc has Hebrew ratio ~30%,
- # so we need a lower threshold AND a consecutive Hebrew words heuristic.
- def _unwrap_hebrew_math_block(match):
- content = match.group(1).strip()
- # Count Hebrew chars vs total
- hebrew_chars = len(re.findall(r'[\u0590-\u05FF]', content))
- total_chars = len(content.replace(' ', ''))
- if total_chars == 0:
- return '' # Empty block, remove it
- hebrew_ratio = hebrew_chars / total_chars
- # Heuristic 1: >25% Hebrew with enough chars = text paragraph
- if hebrew_ratio > 0.25 and hebrew_chars > 8:
- print(f"🧹 [SANITIZE] Unwrapped Hebrew-in-math block ({hebrew_chars} Hebrew chars, ratio={hebrew_ratio:.1%})")
- return content # Return as plain text without $$
- # Heuristic 2: Has 3+ consecutive Hebrew words (even if ratio is low)
- if re.search(r'[\u0590-\u05FF]+\s+[\u0590-\u05FF]+\s+[\u0590-\u05FF]+', content):
- print(f"🧹 [SANITIZE] Unwrapped Hebrew-in-math (consecutive Hebrew words detected)")
- return content
- return match.group(0) # Keep as-is for real math
-
- text = re.sub(r'\$\$(.+?)\$\$', _unwrap_hebrew_math_block, text, flags=re.DOTALL)
-
- # V231.25: Fix corrupted LaTeX escapes (form feed \f, invalid \3)
- text = text.replace('\x0c', r'\f')
- text = re.sub(r'\\(\d)', r'\1', text)
-
- # V275.2: Fix double backslash newlines inside $$...$$ which crash flutter_math_fork
- # Split blocks safely instead of using \newline
- def safe_split_newlines(match):
- block = match.group(1)
- if r'\begin{' in block:
- # Leave environments like \begin{cases} alone, they support \\
- return match.group(0)
- # Split by \\ or \newline
- parts = re.split(r'\\\\|\\newline', block)
- # V280.3: Ensure we don't strip the outer $$ markers when splitting blocks!
- joined = '$$\n$$'.join(p.strip() for p in parts if p.strip())
- return f"$${joined}$$" if joined else ""
-
- text = re.sub(r'\$\$(.+?)\$\$', safe_split_newlines, text, flags=re.DOTALL)
-
- # 1. English Geometrical terms (Case Insensitive)
- # \\b matches word boundaries to avoid replacing substrings
- text = re.sub(r'\\bAngle\\b', r'\\angle', text, flags=re.IGNORECASE)
- text = re.sub(r'\\bTriangle\\b', r'\\triangle', text, flags=re.IGNORECASE)
- text = re.sub(r'\\bDeg\\b', r'^{\\circ}', text, flags=re.IGNORECASE)
-
- # 2. "Area" -> S (e.g. "Area of triangle" -> "S of triangle")
- # Be careful not to replace valid words, but "Area" in math context is usually S
- text = re.sub(r'\\bArea\\b', r'S', text, flags=re.IGNORECASE)
-
- # V262.1: Auto-wrap Hebrew inside LaTeX blocks (The "Escaping Lines" Fix)
- text = _auto_wrap_hebrew_in_latex(text)
-
- return text
-
-def _auto_wrap_hebrew_in_latex(text: str) -> str:
- """
- Scans for Hebrew characters inside $$...$$ or $...$ blocks.
- If found, wraps them in \\text{...} to prevent rendering crashes.
- """
- if not text: return text
-
- # Regex for Hebrew chars (including nikud/punctuation common in Hebrew)
- hebrew_pattern = r'([\u0590-\u05FF\s\.\,\:\-]+)'
-
- def replacer(match):
- content = match.group(1) # The content inside the dollars
- # Check if there is Hebrew in this block
- if re.search(r'[\u0590-\u05FF]', content):
- # There is Hebrew! Let's wrap the Hebrew parts in \text{...}
- # We split by math/hebrew chunks or just wrap the whole Hebrew phrase
- # Simple approach: Find Hebrew chunks and wrap them
- new_content = re.sub(hebrew_pattern, r'\\text{\1}', content)
- # Cleanup: \text{ } (empty) or double wrapping check could be added if needed
- return f"${new_content}$"
- return match.group(0)
-
- # Replace inline math $...$ (using naive non-nested check)
- # We use a trick to avoid matching $$...$$ first if we aren't careful,
- # but specific regex for $$...$$ should come first if we supported it fully as separate.
- # For now, let's handle $...$ which often covers $$...$$ in simple regex unless distinct.
- # Actually, $$ is just two $s. Let's try to be safe.
-
- # Strategy: Split by '$' and process every odd element (1, 3, 5...) as Math?
- # This is safer than regex for nested/complex strings.
-
- parts = text.split('$')
- if len(parts) < 3: return text # No math blocks
-
- new_parts = []
- for i, part in enumerate(parts):
- if i % 2 == 1: # This is a MATH block (inside $...$)
- if re.search(r'[\u0590-\u05FF]', part):
- # Found Hebrew inside Math! Wrap it.
- # Note: We must be careful not to wrap existing \text{...} again if possible,
- # but simple wrapping usually doesn't hurt: \text{\text{...}} is valid-ish or we can ignore.
-
- # Better: only wrap Hebrew that is NOT already in \text{...}?
- # That's complex. Let's do the simple "Wrap Hebrew Chars" regex.
- # We exclude commands commands like \frac, \cdot etc.
-
- def wrap_hebrew(m):
- s = m.group(1)
- if len(s.strip()) == 0: return s # Don't wrap just whitespace
- if '\\text' in s: return s # Already wrapped (naive check)
- return f"\\text{{{s}}}"
-
- # Apply wrapping to identified hebrew chunks
- # Note: we use a simplified version of the regex for local substitution
- part = re.sub(hebrew_pattern, wrap_hebrew, part)
-
- new_parts.append(part)
- else:
- # This is a REGULAR block (outside/between $...$)
- new_parts.append(part)
-
- return '$'.join(new_parts)
-
-def _build_generic_response(llm_output: dict, custom_title: str = None) -> dict:
- """
- V231.20: Rich UI formatter — restores 'Old Look' with green box and mixed text/math.
- ...
- """
- # Extract steps
- steps = []
-
- # helper for clean content
- def get_content(s):
- if isinstance(s, dict):
- # Check multiple content key names (different micro-prompts use different schemas)
- content = s.get("content_mixed", s.get("content", s.get("explanation", s.get("explanation_text", ""))))
-
- # V275.5: If content is still a dict (e.g. from structured JSON fragments), extract text
- if isinstance(content, dict):
- content = content.get("text", content.get("content", str(content)))
- return content
- return str(s)
-
- # V4.3 Unified Markdown Path
- if isinstance(llm_output, dict) and "solution_markdown" in llm_output:
- steps.append({
- "step_id": 1,
- "step_number": 1,
- "title": "פתרון מלא",
- "explanation_text": sanitize_math_text(str(llm_output["solution_markdown"])),
- "content_mixed": sanitize_math_text(str(llm_output["solution_markdown"])),
- "math_artifact": {"type": "equation", "latex": ""},
- "is_unified_markdown": True
- })
- elif (isinstance(llm_output, list)) or (isinstance(llm_output, dict) and "steps" in llm_output and isinstance(llm_output["steps"], list)):
- raw_steps = llm_output if isinstance(llm_output, list) else llm_output["steps"]
- for i, s in enumerate(raw_steps, 1):
- content = get_content(s)
- if isinstance(content, str):
- content = sanitize_math_text(content)
-
- math_latex = ""
- if isinstance(s, dict):
- math_latex = s.get("math_latex", s.get("block_math", s.get("result", "")))
-
- steps.append({
- "step_id": s.get("step_id", i) if isinstance(s, dict) else i,
- "step_number": i,
- "title": s.get("title", f"שלב {i}") if isinstance(s, dict) else f"שלב {i}",
- "explanation_text": content,
- "content_mixed": content,
- "math_artifact": {
- "type": "equation",
- "latex": math_latex
- },
- "block_math": math_latex,
- "teacher_tip": s.get("teacher_tip") if isinstance(s, dict) else None
- })
- elif isinstance(llm_output, dict) and "chain_of_thought" in llm_output:
- # Fallback for old models
- cot = sanitize_math_text(str(llm_output["chain_of_thought"]))
- steps.append({
- "step_id": 1,
- "step_number": 1,
- "title": "דרך הפתרון",
- "explanation_text": cot,
- "content_mixed": cot,
- "math_artifact": {"type": "equation", "latex": ""}
- })
- else:
- # Last resort - use get_content helper to handle single dict blocks correctly
- content = get_content(llm_output)
- if isinstance(content, str):
- content = sanitize_math_text(content)
-
- steps.append({
- "step_id": 1,
- "step_number": 1,
- "title": "הפתרון",
- "explanation_text": content,
- "content_mixed": content,
- "math_artifact": {"type": "equation", "latex": ""}
- })
-
- # Extract final answer
- # Extract final answer with improved fallback logic (V261.16)
- final_answer = (
- llm_output.get("final_answer") or
- llm_output.get("equation") or
- llm_output.get("solution") or
- llm_output.get("derivative") or
- llm_output.get("integral") or
- llm_output.get("limit") or
- llm_output.get("x_intercepts") or
- llm_output.get("min_max_points")
- )
-
- # If it's a list or dict (e.g. points), convert to string representation
- if isinstance(final_answer, (list, dict)):
- final_answer = str(final_answer)
-
- if not final_answer:
- final_answer = "ראה שלבים"
-
- # V8.6: Inject 'approach' as Step 0
- approach = llm_output.get("approach")
- if approach and isinstance(approach, str):
- steps.insert(0, {
- "step_id": 0,
- "step_number": 0,
- "title": "איך ניגשים לזה? 🧭",
- "explanation_text": sanitize_math_text(approach),
- "content_mixed": sanitize_math_text(approach),
- "math_artifact": {"type": "equation", "latex": ""},
- "block_math": ""
- })
-
- response_obj = {
- "sections": [{
- "section_title": custom_title or "הפתרון",
- "steps": steps,
- "section_result": str(final_answer) # V262.0: Per-section result
- }],
- "final_answer": str(final_answer),
- "teacher_closing": llm_output.get("teacher_closing", "כל הכבוד! 🎉"),
- "approach": approach, # V8.6: Explicit approach field
- "teacher_summary": llm_output.get("teacher_summary") # V262.2: Propagate explicit summary
- }
-
- # V260.5: Propagate Investigation Data (Crucial for Table UI)
- if "investigation" in llm_output:
- response_obj["investigation"] = llm_output["investigation"]
- elif "investigation_table" in llm_output:
- response_obj["investigation"] = llm_output["investigation_table"]
-
- return response_obj
-
-if __name__ == "__main__":
- import json
-
- # Test circle equation
- llm_out = {
- "equation": "(x-3)^2 + (y-5)^2 = 25",
- "center": [3, 5],
- "radius": 5
- }
- data = {"center": "(3,5)", "radius": 5}
-
- response = build_pedagogical_response("CIRCLE_EQUATION", llm_out, data)
- print(json.dumps(response, indent=2, ensure_ascii=False))
diff --git a/problem_understanding.py b/problem_understanding.py
deleted file mode 100644
index f89cc07cf6beba9e0be0be09fa0861bcdd38ff98..0000000000000000000000000000000000000000
--- a/problem_understanding.py
+++ /dev/null
@@ -1,155 +0,0 @@
-# problem_understanding.py - V231.14
-# Analyzes problem structure BEFORE solving
-
-"""
-Problem Understanding Module
-Ensures we understand WHAT is being asked before attempting to solve.
-"""
-
-import json
-import re
-from utils.safe_json import safe_extract_json # V1.0: Canonical extractor
-
-
-def get_problem_understanding_prompt(ocr_text: str, data_anchor: dict) -> str:
- """
- Prompt LLM to analyze problem structure.
-
- Returns JSON with:
- - problem_type
- - sub_questions (all parts א, ב, ג, etc.)
- - solving_order
- - dependencies
- """
- return f"""
-ANALYZE this math problem structure. DO NOT SOLVE - only understand what is being asked.
-
-Problem Text:
-{ocr_text}
-
-Extracted Data:
-{json.dumps(data_anchor, ensure_ascii=False, indent=2)}
-
-Your task: Identify ALL parts of this problem and create a solving plan.
-
-Return JSON:
-{{
- "problem_type": "CIRCLE_EQUATION | LINE_EQUATION | GEOMETRIC_LOCUS | DERIVATIVE_QUOTIENT | etc.",
- "main_question": "Brief description of main question",
- "sub_questions": [
- {{
- "id": "א",
- "question": "Full text of sub-question א",
- "requires": ["center", "radius"],
- "specific_values": ["m=2"],
- "expected_output": "equation | number | point | etc.",
- "topic": "CIRCLE_EQUATION"
- }},
- {{
- "id": "ב",
- "question": "Full text of sub-question ב",
- "requires": ["equation_from_א", "point"],
- "specific_values": ["a=1"],
- "expected_output": "line_equation",
- "topic": "LINE_TANGENT"
- }}
- ],
- "solving_order": ["א", "ב", "ג"],
- "dependencies": {{
- "ב": ["א"],
- "ג": ["א"]
- }}
-}}
-
-CRITICAL RULES:
-1. Include ALL sub-questions (א, ב, ג, ד, etc.)
-2. **CHRONOLOGICAL DATA ISOLATION (V310.0 - CRITICAL):** If a specific value (e.g., a=1, m=2) is mentioned ONLY in a specific sub-question, you MUST include it in the `specific_values` array for THAT sub-question only. NEVER put it in the top-level anchor if it's not global. This prevents data leakage (e.g., using a=1 from section ב' to solve section א').
-3. **EXCEPTION:** If the problem asks for a **Geometric Locus (מקום גיאומטרי)**:
- - This is a SINGLE QUESTION (even if it looks long).
- - Set `problem_type` = "GEOMETRIC_LOCUS".
- - Create ONLY ONE sub-question (id="א") containing the entire text.
-4. Identify dependencies (ב needs א's result)
-5. Determine topic for EACH sub-question
-6. DO NOT solve - only analyze structure
-
-Return ONLY valid JSON.
-"""
-
-
-def parse_understanding(response_text: str) -> dict:
- """Parse LLM understanding response using canonical safe_extract_json."""
- result = safe_extract_json(response_text, caller="PROBLEM_UNDERSTANDING")
- # If parsing failed, return a minimal fallback so the pipeline continues
- if isinstance(result, dict) and result.get("logic_error"):
- return {
- "problem_type": "UNKNOWN",
- "sub_questions": [],
- "solving_order": [],
- "dependencies": {}
- }
- return result
-
-
-
-def validate_understanding(understanding: dict) -> bool:
- """Validate understanding structure."""
- required_keys = ["problem_type", "sub_questions", "solving_order"]
-
- if not all(key in understanding for key in required_keys):
- return False
-
- if not isinstance(understanding["sub_questions"], list):
- return False
-
- if len(understanding["sub_questions"]) == 0:
- return False
-
- # Validate each sub-question
- for sq in understanding["sub_questions"]:
- if not all(key in sq for key in ["id", "question", "topic"]):
- return False
-
- return True
-
-def enforce_locus_rule(understanding: dict, ocr_text: str) -> dict:
- """
- V260.2: Hard Rule - If 'מקום גיאומטרי' exists, FORCE Locus type.
- """
- if any(k in ocr_text for k in ["מקום גיאומטרי", "Locus", "מצא את המקום", "המקום הגיאומטרי"]):
- print("🛡️ [BIT-LOG] Hard Logic: Detected 'Geometric Locus' - Forcing Single Question structure.")
- return {
- "problem_type": "GEOMETRIC_LOCUS",
- "main_question": understanding.get("main_question", "Find the Locus"),
- "sub_questions": [{
- "id": "א",
- "question": ocr_text, # Give the WHOLE text to the single sub-question
- "requires": [],
- "expected_output": "equation",
- "topic": "GEOMETRIC_LOCUS"
- }],
- "solving_order": ["א"],
- "dependencies": {}
- }
- return understanding
-
-
-# ==================== USAGE EXAMPLE ====================
-
-if __name__ == "__main__":
- # Test understanding prompt
- ocr = """
- מעגל עם משוואה x² + y² = 12
- א. מצא את משוואת המעגל
- ב. מצא משיק למעגל בנקודה A
- ג. מצא את שטח המעגל
- """
-
- data = {
- "function_equations": ["x^2 + y^2 = 12"],
- "points": ["A"],
- "specific_values": [],
- "constraints": []
- }
-
- prompt = get_problem_understanding_prompt(ocr, data)
- print(prompt)
diff --git a/prompts.py b/prompts.py
deleted file mode 100644
index 1b3fc54c90b663845868cc598f87dc4114b86c52..0000000000000000000000000000000000000000
--- a/prompts.py
+++ /dev/null
@@ -1,937 +0,0 @@
-# prompts.py - V275.0 (Enhanced OCR for Nested Fractions)
-# Based on BUDDYMATH_COMPLETE_GUIDE V230.8 §1.3, §4.1, §6.1
-import json
-
-
-def _get_grade_features(grade: str, category: str = "") -> dict:
- """התאמת רמת הפירוט לפי יחידות לימוד (§5.1)"""
- is_investigation = (category == "INVESTIGATION")
-
- if "5 יח\"ל" in grade:
- style = "הוכחה דקדקנית של כל מעבר, כולל נגזרות שנייה, קמירות ואסימפטוטות" if is_investigation else "הוכחה דקדקנית של כל מעבר ודיוק אלגברי מירבי"
- return {
- "depth": "אקדמי ומעמיק",
- "style": style,
- "tone": "מעצים ומאתגר, כביר של בגרות 5 יח\"ל"
- }
- elif "4 יח\"ל" in grade:
- style = "הסבר ברור של חוקי האלגברה עם דגש על כלל שרשרת" if is_investigation else "הסבר ברור של חוקי האלגברה ופירוט תהליכי הפתרון"
- return {
- "depth": "מפורט ותומך",
- "style": style,
- "tone": "מעודד ומפורט, שלב אחר שלב"
- }
- else:
- return {
- "depth": "פשוט, ברור ומדובר 'בגובה העיניים' (מבחן הילד בן ה-16)",
- "style": "צעד אחר צעד, בקריאה שוטפת. חובה להשתמש במשפטי קישור ומעבר מילוליים (כמו 'נציב את הנתונים בנוסחה:', 'כעת נבדוק את תחום ההגדרה:'). אסור להרצות.",
- "tone": "חם, סבלני, מלא התלהבות ועידוד תמידי. כמו מורה אהובה שעוזרת באופן פרטי."
- }
-
-def _detect_relevant_rules(text: str, category: str = "") -> list:
- """זיהוי כללים רלוונטיים לפי קטגוריה — מבוסס-הקשר (§4.1, V231.1)"""
- rules = []
- t = text.lower()
-
- # === GEOMETRY rules (only for GEOMETRY category) ===
- if category != "INVESTIGATION":
- if any(x in t for x in ["מעגל", "מרכז", "רדיוס", "מקום גיאומטרי"]):
- rules.append(r"משוואת מעגל: $(x-a)^2 + (y-b)^2 = r^2$")
- rules.append(r"היקף מעגל: $2\\pi r$")
- if "מרחק" in t or "d =" in t or "מקום גיאומטרי" in t:
- rules.append(r"דיסטנס: $d = \\sqrt{(x_2-x_1)^2 + (y_2-y_1)^2}$")
- if any(x in t for x in ["ישר", "שיפוע"]):
- rules.append(r"משוואת ישר: $y = mx + b$")
- if any(x in t for x in ["משולש", "שטח"]):
- rules.append(r"שטח משולש: $S = \\frac{1}{2} \\cdot a \\cdot h$")
- if any(x in t for x in ["משיק", "אנך"]):
- rules.append(r"שיפועים אנכיים: $m_1 \\cdot m_2 = -1$")
- if "מקום גיאומטרי" in t:
- rules.append(r"פרבולה: מרחק מנקודה שווה למרחק מישר.")
- rules.append(r"אליפסה: סכום מרחקים מ-2 נקודות קבוע ($d_1+d_2=k$).")
- rules.append(r"היפרבולה: הפרש מרחקים מ-2 נקודות קבוע ($|d_1-d_2|=k$).")
- if "מקום גיאומטרי" in t:
- rules.append(r"⚠️ הנחיה קריטית: אל תנחש את הצורה! פיתוח המשוואה חייב להיעשות צעד-אחר-צעד מההגדרה המילולית.")
- rules.append(r"דוגמה לשימוש בהגדרה: אם רשום שמרחק נקודה $P(x,y)$ ממיקום א' שווה למרחקה ממיקום ב', רשום משוואה מהצורה $d_1 = d_2$. הצב את נוסחאות המרחק, העלה בריבוע ופשט.")
- rules.append(r"בביטויים עם שורשים: בודד שורש אחד -> עלה בריבוע -> בודד את השורש השני -> עלה בריבוע שוב.")
-
- # === PROOF-specific rules (V282.0) ===
- if any(x in t for x in ["הוכח", "הוכיח", "הוכיחו", "הראה כי", "הראי כי", "הוכח כי", "הוכיחי"]):
- rules.append(r"⚠️ זוהי שאלת הוכחה! חובה להראות את כל שרשרת ההיגיון, לא רק את התוצאה.")
- rules.append(r"מבנה הוכחה: נתון -> מסקנה ביניים (+ נימוק/משפט) -> מסקנה הבאה -> ... -> מ.ש.ל.")
- rules.append(r"לכל מעבר לוגי חובה לציין את הנימוק: שם המשפט, התכונה, או הכלל.")
- rules.append(r"משולש שווה שוקיים -> זוויות בסיס שוות, חוצה זווית = תיכון = גובה לבסיס.")
- rules.append(r"משולשים חופפים: צ.צ.צ / צ.ז.צ / ז.צ.ז - ציין איזה קריטריון נבחר ולמה.")
- rules.append(r"אם צריך להוכיח שוויון קטעים - חפש משולשים חופפים, או תכונות של צורות מיוחדות.")
-
- rules.append(r"בגיאומטריה אנליטית: לפני חישוב, בצע 'בדיקת שפיות' (Sanity Check).")
- rules.append(r"אם נתון ש-AC קוטר, המרכז M *חייב* להיות האמצע שלו. אם החישוב מראה אחרת - הנתונים שהוצאת שגויים! נסה לקרוא שוב.")
- rules.append(r"עדיפות לנתונים: טקסט כתוב > משוואות > שרטוט ויזואלי.")
- rules.append(r"הימנע משימוש ב-\\\\ בתוך בלוקים של מתמטיקה, השתמש בצעדים נפרדים.")
-
- # V8.6.3: Contextual Logic Guardrails
- if category == "GEOMETRY":
- rules.append(r"ZERO ASSUMPTIONS RULE: NEVER assume, guess, or invent ANY geometric property (e.g., 'this segment is a diameter', 'this angle is 90 degrees', 'this triangle is isosceles') unless it is EXPLICITLY WRITTEN in the provided text. You are strictly forbidden from fabricating data or relationships just because the coordinates look convenient. Work ONLY with the exact facts stated in the OCR text and Data Anchor.")
- rules.append(r"CRITICAL GEOMETRY RULE: You MUST strictly adhere to the geometric layout described in the problem text. If your calculation leads to a paradox (e.g., length 0, points colliding, or negative area), YOUR assumption is wrong. Recalculate based strictly on the given explicit constraints. DO NOT change the given equations.")
-
- if category in ["CALCULUS", "FUNCTION_ANALYSIS", "INVESTIGATION"]:
- rules.append(r"GRAPH MATCHING PROTOCOL: When asked to select a correct graph from multiple options (I, II, III, IV), you MUST first perform a visual scan: explicitly describe what you see in EACH given graph (e.g., 'Graph I shows asymptotes at... Graph II shows a hole at...'). Only AFTER describing all options, match your mathematical deductions to the correct visual description and select the final answer.")
-
- if category == "CALCULUS":
- if any(x in t for x in ["נגזרת", "גזור", "f'", "חקור"]):
- rules.append(r"כלל החזקה: $(x^n)' = nx^{n-1}$")
- if any(x in t for x in ["/", "frac", "מנה", "חילוק"]):
- rules.append(r"נגזרת מנה: $(\\frac{u}{v})' = \\frac{u'v - uv'}{v^2}$")
- if any(x in t for x in ["מכפלה", "כפל"]):
- rules.append(r"נגזרת מכפלה: $(u \\cdot v)' = u'v + uv'$")
- if any(x in t for x in ["שרשרת", "הרכבה", "sin", "cos"]):
- rules.append(r"כלל שרשרת: $[f(g(x))]' = f'(g(x)) \\cdot g'(x)$")
- if any(x in t for x in ["אינטגרל", "שטח מתחת"]):
- rules.append(r"אינטגרל חזקה: $\\int x^n dx = \\frac{x^{n+1}}{n+1} + C$")
- if any(x in t for x in ["דיפרנציאלית", "y'", "dy/dx"]):
- rules.append(r"משוואה דיפרנציאלית: הפרד משתנים, אינטגרל משני הצדדים.")
-
- # === SEQUENCES rules ===
- if any(x in t for x in ["סדרה", "חשבונית", "הנדסית", "סכום חלקי", "הפרש"]):
- rules.append(r"סדרה חשבונית: $a_n = a_1 + (n-1)d$, סכום: $S_n = \\frac{n}{2}(a_1 + a_n)$")
- rules.append(r"סדרה הנדסית: $a_n = a_1 \\cdot q^{n-1}$, סכום: $S_n = a_1 \\cdot \\frac{q^n - 1}{q - 1}$")
- if "אינסופי" in t or "גבול" in t:
- rules.append(r"סכום סדרה הנדסית אינסופית ($|q|<1$): $S = \\frac{a_1}{1-q}$")
-
- # === COMPLEX NUMBERS rules ===
- if any(x in t for x in ["מרוכב", "מדומה", "i²", "i^2"]):
- rules.append(r"$i^2 = -1$, מספר מרוכב: $z = a + bi$")
- rules.append(r"מודולוס: $|z| = \\sqrt{a^2 + b^2}$, צמוד: $\\bar{z} = a - bi$")
- if any(x in t for x in ["קוטבי", "דה-מואבר", "טריגונומטרי"]):
- rules.append(r"צורה טריגונומטרית: $z = r(\\cos\\theta + i\\sin\\theta)$")
- rules.append(r"דה-מואבר: $z^n = r^n(\\cos(n\\theta) + i\\sin(n\\theta))$")
-
- # === VECTORS rules ===
- if any(x in t for x in ["וקטור", "וקטורים"]):
- rules.append(r"$\\vec{u} = (a,b)$, $|\\vec{u}| = \\sqrt{a^2+b^2}$")
- rules.append(r"מכפלה סקלרית: $\\vec{u}\\cdot\\vec{v} = a_1 a_2 + b_1 b_2 = |u||v|\\cos\\alpha$")
- rules.append(r"וקטורים מאונכים: $\\vec{u}\\cdot\\vec{v} = 0$")
- # V286.0: 3D Vectors (5 יח"ל)
- if any(x in t for x in ["מרחב", "מישור", "תלת", "z", "פרמטרי", "ניצב למישור"]):
- rules.append(r"וקטור במרחב: $\vec{u} = (a,b,c)$, $|\vec{u}| = \sqrt{a^2+b^2+c^2}$")
- rules.append(r"מכפלה סקלרית 3D: $\vec{u}\cdot\vec{v} = a_1 a_2 + b_1 b_2 + c_1 c_2 = |u||v|\cos\alpha$")
- rules.append(r"מכפלה וקטורית (Cross Product): $\vec{u}\times\vec{v} = (b_1 c_2 - c_1 b_2,\; c_1 a_2 - a_1 c_2,\; a_1 b_2 - b_1 a_2)$. הוקטור שמתקבל ניצב לשני הוקטורים המקוריים.")
- rules.append(r"מציאת נורמלי למישור: השתמש במכפלה וקטורית של שני וקטורי כיוון הפורשים את המישור.")
- rules.append(r"משוואת מישור: $Ax + By + Cz + D = 0$. הנורמלי הוא $\vec{n} = (A,B,C)$.")
- rules.append(r"ישר במרחב (הצגה פרמטרית): $\vec{r} = \vec{p} + t\vec{v}$")
- 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}}$")
- rules.append(r"זווית $\alpha$ בין שני מישורים: $\cos\alpha = \frac{|\vec{n_1}\cdot\vec{n_2}|}{|\vec{n_1}||\vec{n_2}|}$")
- rules.append(r"זווית $\beta$ בין ישר למישור: $\sin\beta = \frac{|\vec{v}\cdot\vec{n}|}{|\vec{v}||\vec{n}|}$ (כאשר $\vec{n}$ הוא נורמלי למישור).")
- rules.append(r"מכפלה משולשת (נפח מקבילון): $V = |\vec{a}\cdot(\vec{b}\times\vec{c})|$")
-
- # === STATISTICS rules ===
- if any(x in t for x in ["ממוצע", "חציון", "שכיח", "סטיית תקן", "שונות"]):
- rules.append(r"ממוצע: $\\bar{x} = \\frac{\\sum x_i}{n}$")
- rules.append(r"שונות: $\\sigma^2 = \\frac{\\sum(x_i - \\bar{x})^2}{n}$, סטיית תקן: $\\sigma = \\sqrt{\\sigma^2}$")
- if any(x in t for x in ["התפלגות", "נורמלית", "בינומית"]):
- rules.append(r"התפלגות בינומית: $P(X=k) = \\binom{n}{k}p^k(1-p)^{n-k}$")
- rules.append(r"התפלגות נורמלית: כלל 68-95-99.7 (אחוזים בטווח 1-2-3 סטיות תקן)")
-
- # === INDUCTION rules ===
- if any(x in t for x in ["אינדוקציה", "הוכח כי לכל", "n טבעי"]):
- rules.append(r"שלב 1 (בסיס): הוכח עבור $n=1$. שלב 2 (הנחה): הנח עבור $n=k$. שלב 3 (צעד): הוכח עבור $n=k+1$.")
- rules.append(r"⚠️ חובה לרשום בפירוש: 'מה צריך להוכיח', 'הנחת האינדוקציה', 'מ.ש.ל'.")
-
- # === TRIGONOMETRY LAW rules ===
- if any(x in t for x in ["סינוסים", "קוסינוסים", "משולש", "זווית"]):
- if "סינוסים" in t:
- rules.append(r"משפט הסינוסים: $\\frac{a}{\\sin A} = \\frac{b}{\\sin B} = \\frac{c}{\\sin C}$")
- if "קוסינוסים" in t:
- rules.append(r"משפט הקוסינוסים: $c^2 = a^2 + b^2 - 2ab\\cos C$")
- rules.append(r"שטח משולש לפי שתי צלעות וזווית: $S = \\frac{1}{2}ab\\sin C$")
- if any(x in t for x in ["זהות", "טריגונומטרית"]):
- rules.append(r"$\\sin^2(x) + \\cos^2(x) = 1$")
- rules.append(r"$\\sin(2x) = 2\\sin(x)\\cos(x)$, $\\cos(2x) = \\cos^2(x) - \\sin^2(x)$")
- # V286.0: Advanced Trigonometry (5 יח"ל)
- if any(x in t for x in ["sin", "cos", "tan", "trig", "טריגונומטר", "sin(", "cos("]):
- if any(x in t for x in ["סכום", "הפרש", "α+β", "α-β", "alpha"]):
- rules.append(r"$\\sin(\\alpha \\pm \\beta) = \\sin\\alpha\\cos\\beta \\pm \\cos\\alpha\\sin\\beta$")
- rules.append(r"$\\cos(\\alpha \\pm \\beta) = \\cos\\alpha\\cos\\beta \\mp \\sin\\alpha\\sin\\beta$")
- rules.append(r"$\\tan(\\alpha + \\beta) = \\frac{\\tan\\alpha + \\tan\\beta}{1 - \\tan\\alpha\\tan\\beta}$")
- if any(x in t for x in ["חצי זווית", "sin²", "cos²"]):
- rules.append(r"$\\sin^2\\frac{x}{2} = \\frac{1-\\cos x}{2}$, $\\cos^2\\frac{x}{2} = \\frac{1+\\cos x}{2}$")
- if any(x in t for x in ["פתור משוואה טריגונומטרית", "sinx=", "cosx=", "sin x =", "cos x ="]):
- rules.append(r"$\\sin x = a \\Rightarrow x = (-1)^n \\arcsin a + \\pi n$, $n \\in \\mathbb{Z}$")
- rules.append(r"$\\cos x = a \\Rightarrow x = \\pm \\arccos a + 2\\pi n$, $n \\in \\mathbb{Z}$")
- rules.append(r"$\\tan x = a \\Rightarrow x = \\arctan a + \\pi n$, $n \\in \\mathbb{Z}$")
-
- # === VOLUME & SURFACE AREA rules ===
- if any(x in t for x in ["נפח", "שטח פנים", "גליל", "חרוט", "כדור"]):
- rules.append(r"נפח גליל: $V = \\pi r^2 h$, נפח חרוט: $V = \\frac{1}{3}\\pi r^2 h$")
- rules.append(r"נפח כדור: $V = \\frac{4}{3}\\pi r^3$, שטח פנים כדור: $S = 4\\pi r^2$")
-
- # V286.0: Solid Geometry (גיאומטריה מרחבית — 5 יח"ל)
- if any(x in t for x in ["פירמידה", "מנסרה", "תיבה", "חתך", "אפותם", "מקצוע צדדי", "פאות"]):
- rules.append(r"נפח פירמידה: $V = \frac{1}{3} S_{base} \cdot h$")
- rules.append(r"נפח מנסרה: $V = S_{base} \cdot h$")
- rules.append(r"שטח פנים של פירמידה: $S = S_{base} + \sum S_{faces}$")
- rules.append(r"זווית $\alpha$ בין מקצוע צדדי לבסיס: זווית במשולש ישר-זווית הנוצר ע\"י המקצוע, הטלו על הבסיס והגובה.")
- rules.append(r"זווית $\beta$ בין פאה צדדית לבסיס: זווית במשולש ישר-זווית הנוצר ע\"י האפותם של הפאה, הטלו על הבסיס והגובה.")
- rules.append(r"חישובים מרחביים: השתמש תמיד במשפט פיתגורס וטריגונומטריה בתוך משולשים ישרי-זווית המקשרים פנים וחוץ.")
-
- # === INEQUALITY rules ===
- if any(x in t for x in ["אי-שוויון", "אי שוויון"]):
- rules.append(r"⚠️ בכפל/חילוק באי-שוויון במספר שלילי — הפוך את כיוון הסימן!")
- if "ריבועי" in t:
- rules.append(r"אי-שוויון ריבועי: פתור כמשוואה, בדוק סימן בקטעים.")
-
- # V286.0: Logarithms & Exponentials (לוגריתמים — 5 יח"ל)
- if any(x in t for x in ["לוגריתם", "log", "ln", "מעריכי", "e^"]):
- rules.append(r"$\\log_a(xy) = \\log_a x + \\log_a y$")
- rules.append(r"$\\log_a\\left(\\frac{x}{y}\\right) = \\log_a x - \\log_a y$")
- rules.append(r"$\\log_a(x^n) = n\\log_a x$")
- rules.append(r"החלפת בסיס: $\\log_a b = \\frac{\\ln b}{\\ln a}$")
- rules.append(r"$a^x = e^{x\\ln a}$, $\\log_a a = 1$, $\\log_a 1 = 0$")
- rules.append(r"$\\ln e = 1$, $e^{\\ln x} = x$, $\\ln(e^x) = x$")
-
- # V286.0: Optimization Problems (בעיות קיצון — 5 יח"ל)
- if any(x in t for x in ["קיצון", "מקסימום", "מינימום", "שטח גדול ביותר", "נפח מקס", "ערך מרבי", "ערך מזערי", "אופטימיזציה"]):
- rules.append(r"אסטרטגיה לבעיות אופטימיזציה (5 יח\"ל):")
- rules.append(r"1) הגדר משתנה (x) ובטא את כל הגדלים האחרים בעזרתו.")
- rules.append(r"2) בנה פונקציית מטרה $f(x)$ עבור הגודל שצריך למקסם/למזער.")
- rules.append(r"3) השתמש באילוצים כדי להגיע לפונקציה של משתנה אחד בלבד.")
- rules.append(r"4) גזור את פונקציית המטרה, השווה ל-0 ומצא את הנקודות החשודות.")
- rules.append(r"5) הוכח את סוג הקיצון (Max/Min) בעזרת טבלה או נגזרת שנייה.")
- rules.append(r"6) בדוק תמיד נקודות קצה של התחום אם הבעיה מוגדרת בקטע סגור.")
-
- # V286.0: Probability & Combinatorics (הסתברות וקומבינטוריקה — 5 יח"ל)
- if any(x in t for x in ["הסתברות", "קומבינטור", "עצי", "עץ", "תמורה", "צירוף", "בייס", "מותנה", "בלתי תלוי"]):
- rules.append(r"$P(A \\cup B) = P(A) + P(B) - P(A \\cap B)$")
- rules.append(r"הסתברות מותנית: $P(A|B) = \\frac{P(A \\cap B)}{P(B)}$")
- rules.append(r"נוסחת בייס: $P(B_i|A) = \\frac{P(A|B_i)P(B_i)}{\\sum_j P(A|B_j)P(B_j)}$")
- rules.append(r"חוק ההסתברות השלמה: $P(A) = \\sum_i P(A|B_i)P(B_i)$")
- rules.append(r"תמורות: $P(n,k) = \\frac{n!}{(n-k)!}$, צירופים: $\\binom{n}{k} = \\frac{n!}{k!(n-k)!}$")
- rules.append(r"אירועים בלתי תלויים: $P(A \\cap B) = P(A) \\cdot P(B)$")
-
- # V286.0: Advanced Calculus (חדו"א מתקדם — 5 יח"ל)
- if any(x in t for x in ["אינטגרל", "שטח מתחת", "נפח סיבוב", "אינטגרציה"]):
- if any(x in t for x in ["חלקי", "חלקים", "by parts"]):
- rules.append(r"אינטגרציה בחלקים: $\\int u\\,dv = uv - \\int v\\,du$")
- if any(x in t for x in ["הצבה", "substitution"]):
- rules.append(r"אינטגרציה בהצבה: $\\int f(g(x))g'(x)dx = \\int f(u)du$ כאשר $u=g(x)$")
- rules.append(r"שטח בין שני גרפים: $S = \\int_a^b |f(x) - g(x)|\\,dx$")
- if any(x in t for x in ["סיבוב", "גוף סיבוב"]):
- rules.append(r"נפח גוף סיבוב סביב ציר $x$: $V = \\pi\\int_a^b [f(x)]^2\\,dx$")
- if any(x in t for x in ["לא אמיתי", "מוכלל", "improper"]):
- rules.append(r"אינטגרל מוכלל: $\\int_a^{\\infty} f(x)dx = \\lim_{b\\to\\infty} \\int_a^b f(x)dx$")
-
- # V286.0: Limits (גבולות — 5 יח"ל)
- if any(x in t for x in ["גבול", "lim", "שואף", "אינסוף", "לופיטל"]):
- rules.append(r"גבולות נחשבים: $\\lim_{x\\to 0}\\frac{\\sin x}{x} = 1$")
- rules.append(r"$\\lim_{x\\to\\infty}\\left(1+\\frac{1}{x}\\right)^x = e$")
- rules.append(r"כלל לופיטל: אם $\\frac{0}{0}$ או $\\frac{\\infty}{\\infty}$, אז $\\lim\\frac{f(x)}{g(x)} = \\lim\\frac{f'(x)}{g'(x)}$")
- rules.append(r"אסטרטגיה ל-$\\frac{0}{0}$: פרק לגורמים, רציונליזציה, או לופיטל.")
- rules.append(r"אסטרטגיה ל-$\\frac{\\infty}{\\infty}$ עם פולינומים: חלק במעלה הגבוהה ביותר.")
-
- return rules
-
-def get_data_extraction_prompt(problem_text: str) -> str:
- """V231.15: Template-based extraction to avoid f-string escape hell."""
- template = r"""
-### [SACRED TRUTH: IMAGE OVER OCR]
-You are provided with an IMAGE and its rough OCR transcription.
-⚠️ CRITICAL WARNING: The OCR text is a WEAK HINT and is often WRONG, truncated, or mangled.
-💎 SACRED TRUTH: The IMAGE is the absolute source of truth.
-
-YOUR TASK:
-1. PERCEPTUAL PRIORITY: Visually inspect the IMAGE meticulously. Every pixel of the formula counts.
-2. TOP-DOWN HIERARCHY: Start from the very top of the image. The main function (e.g., f(x)=...) is usually the first mathematical line. DO NOT MISS IT.
-3. OCR SKEPTICISM: The OCR text is often a "hallucination hint" for formulas. If the OCR text says h(x) but the IMAGE shows f(x) at the top, the IMAGE'S f(x) is the SACRED TRUTH.
-4. LTR MATHEMATICAL FLOW: Equations are NOT Hebrew. They are read horizontally LEFT-TO-RIGHT. Ensure multipliers, exponents, and fractions are extracted in their visual LTR order.
-5. NO INVENTIONS: Do not "fix" the math. If it looks incomplete, extract what is there.
-
-Extract:
-1. **function_equations**: ALL equations with '=' sign
- - ⚠️ **ABSOLUTE PRIORITY**: Extract the primary function (f(x), y=...) first.
- - Functions: f(x) = ..., g(x) = ...
- - Circles: x² + y² = r², (x-a)² + (y-b)² = r²
- - Lines: y = mx + b, ax + by = c
- - ANY equation with '=' sign!
-
-**Mathematical Scanning Directionality (V8.9.3):**
-- **LTR PRIORITY:** Mathematical equations are STRICTLY Left-To-Right.
-- When extracting formulas embedded in Hebrew text, IGNORE the RTL flow.
-- Read the characters in their literal horizontal visual order from LEFT to RIGHT.
-- Ensure multipliers, exponents, and signs are placed exactly where they appear visually.
-
-**Visual Context & Continuity (V8.9.5):**
-- **SPLIT EQUATIONS:** If an equation starts on one line and ends on another, merge them into a single coherent formula.
-- **SEGMENTED CONSTRAINTS:** Look for constraints (like x > 0) near equations; they are often visually separated by space or Hebrew words but belong to the formula.
-- **MULTI-PART ANCHORING:** Maintain consistency between sub-questions (א, ב, ג). If a variable 'm' is defined in the preamble, it applies to all sub-questions.
-
-2. **points**: Named points like A, B, M(3,5), P(x,y)
-
-3. **specific_values**: Numbers like r=5, a=3, m=2
-
-4. **constraints**: Conditions like x>0, x≠2, domain restrictions
-
-5. **sub_questions**: Parts א, ב, ג or a, b, c
-
-6. **geometric_anchors** (CRITICAL FOR VALIDATION):
- - center: [x,y] coordinates if a circle center is given
- - radius: numeric value if radius is given
- - point_a, point_b: Strings like "(x,y)" if specific points are given for distance/lines
-
-JSON format (STRUCTURE ONLY - DO NOT USE THESE EXACT VALUES):
-{{
- "function_equations": ["", ""],
- "points": ["", "(,)"],
- "specific_values": ["="],
- "constraints": [""],
- "sub_questions": [""],
- "center": [null, null],
- "radius": null,
- "point_a": "(null, null)",
- "point_b": "(null, null)"
-}}
-
-
-**CRITICAL JSON STRUCTURE RULE (V8.9.4):**
-- You MUST output a SINGLE, flat JSON object.
-- EVERY mathematical expression, function, or parameter MUST have a UNIQUE, highly descriptive key.
-- DO NOT use a generic key like "equation" multiple times, as this will overwrite the data.
-- DO NOT output an array/list of objects.
-- **Correct Example:** { "main_function_f": "f(x)=...", "function_h": "h(x)=...", "given_extremum_x": "x=1/e" }
-- **Wrong Example:** { "equation": "f(x)=...", "equation": "h(x)=..." }
-
-CRITICAL INSTRUCTIONS:
-1. Include ALL equations with '=' sign in function_equations!
-2. **DATA INTEGRITY (V4.3.0):** Use the data below verbatim.
-3. **VARIABLE ENFORCEMENT (V4.0.1):** ALL equations must use standard single-letter mathematical variables (x, y, z, a, b, c, m, n, k).
- - DO NOT extract descriptive English names like "number_of_notebooks".
- - 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').
- - Equations with descriptive multi-letter variables will crash the calculator.
-4. DO NOT HALLUCINATE OR GUESS! 🚫 If an equation or point is NOT explicitly written in the problem, DO NOT invent it.
-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).
-6. Example: Do not assume `f(x) = ax - x^2` just because it looks like a standard problem. Only extract what is literally there.
-7. **SUB-QUESTION MAPPING (CRITICAL - V231.14):** You MUST map ALL sub-questions present in the OCR text (e.g., א, ב, ג, ד). Do NOT group them into one question and do NOT stop at the first one. Each sub-question letter requires its own entry in the `sub_questions` array.
-8. **DATA SCOPING (V8.6.9 - CRITICAL):** Only extract data that is truly GLOBAL (applies to all sections). If a value or constraint is explicitly tied to a specific section (e.g., "בסעיף ב' נתון כי a=1"), do NOT put it in the `specific_values` of the main anchor. The problem understanding phase will handle the local data.
-9. **CHRONOLOGICAL ISOLATION (V310.0):** If a variable is assigned a value in one sub-question, it MUST NOT be used in previous sub-questions.
-r"""
-
-def get_specialist_prompt(category, problem_text, solver_hint, grade, student_name, student_gender="M", data_anchor=None):
- """בניית הפרומפט המלכותי — המורה למתמטיקה V231.6 (Data Anchor)"""
- features = _get_grade_features(grade, category)
- relevant_rules = _detect_relevant_rules(problem_text, category)
- rules_str = "\n".join([f" - {r}" for r in relevant_rules]) if relevant_rules else f" (לא זוהו כללים ספציפיים — בחר רק כללים רלוונטיים לקטגוריה {category})"
-
- # Anchor Block — DATA INTEGRITY RULE
- anchor_block = ""
- geo_verified_block = "" # V1.0: Geometric Sanity Engine output
- if data_anchor:
- # Extract and remove internal sanity keys before JSON serialization
- geo_prompt_block = data_anchor.pop("_geo_prompt_block", "")
- data_anchor.pop("_verified_facts", None)
- data_anchor.pop("_geometry_warnings", None)
-
- anchor_block = f"""
- ══════════════════════════════════════════════════════
- 📜 DATA INTEGRITY RULE (ABSOLUTE TRUTH):
- ══════════════════════════════════════════════════════
- The data below is the ABSOLUTE TRUTH extracted from the student's image.
- If it contains function_equations or equations — those ARE the problem's data.
- YOU MUST use them EXACTLY AS GIVEN. Never claim data is "missing" if it is in the data.
- Never assume, invent, or substitute a different function under ANY circumstances.
- Violating this rule is a critical pedagogical failure.
- ══════════════════════════════════════════════════════
- נתוני שאלת המקור (השתמש רק בהם):
- {json.dumps(data_anchor, indent=2, ensure_ascii=False)}
-
- CONSTRAINT: If the data says A(0,5), use A(0,5). If it contains f(x), solve that f(x).
- """
- # V1.0: Inject verified geometric facts (if the sanity engine found anything)
- if geo_prompt_block:
- geo_verified_block = geo_prompt_block
-
- # V231.5: Gender-aware phrases
- if student_gender == "F":
- g = {
- "royal": "נסיכה",
- "come": "בואי",
- "ready": "מוכנה",
- "great": "מעולה",
- "solved": "פתרת",
- "proud": "גאה בך",
- "try_again": "בואי ננסה שוב יחד",
- "example_open": f"כל הכבוד {student_name} נסיכה! 👑",
- "example_close": f"כל הכבוד {student_name}! 👑 {{'פתרת' if student_gender == 'F' else 'פתרת'}} מעולה. אני גאה בך!"
- }
- else:
- g = {
- "royal": "נסיך",
- "come": "בוא",
- "ready": "מוכן",
- "great": "מעולה",
- "solved": "פתרת",
- "proud": "גאה בך",
- "try_again": "בוא ננסה שוב יחד",
- "example_open": f"כל הכבוד {student_name} נסיך! 👑",
- "example_close": f"כל הכבוד {student_name}! 👑 פתרת מעולה. אני גאה בך!"
- }
- # V282.0: Proof-specific instructions (outside f-string to avoid backslash issues in Python 3.11)
- proof_keywords = ["הוכח", "הוכיח", "הוכיחו", "הראה כי", "הראי כי"]
- is_proof = any(x in problem_text for x in proof_keywords)
- proof_block = ""
- if is_proof:
- proof_block = """
- ═══════════════════════════════════════════════════
- 📐 הוראות מיוחדות להוכחות (V282.0):
- ═══════════════════════════════════════════════════
-
- 🔴 זוהי שאלת הוכחה! החוקים הבאים הם חובה:
- 1. **אסור לקפוץ לתשובה.** ההוכחה היא הדרך, לא התוצאה. התלמיד צריך לראות כל צעד.
- 2. **מבנה חובה לכל צעד:** "נתון ש-[X]. לפי [שם המשפט/תכונה], מתקיים [Y]."
- 3. **נימוק לכל מעבר:** לכל שוויון/אי-שוויון, ציין למה הוא נכון.
- 4. **שרשרת לוגית:** כל צעד חייב להסתמך על צעד קודם או על נתון.
- 5. **ציין שיטת הוכחה:** חפוש משולשים חופפים? תכונות של שווה שוקיים? זוויות מתחלפות?
- 6. **סיום:** בסוף חובה לכתוב "ולכן הוכחנו ש-[מה שנדרש]. מ.ש.ל."
- 7. **דוגמה למבנה טוב:**
- - "נתון שהמשולש ABC שווה שוקיים (AB = AC). לפי תכונה: זוויות בסיס שוות."
- - "חישבנו שזווית CDF שווה לזווית DCF. לפי משפט: במשולש ששתי זוויות בו שוות, הצלעות מולן שוות, DC = CF."
- 8. **אסור:** לכתוב "DC = CF" בלי להסביר למה. חובה לבנות את כל שרשרת ההוכחה.
- """
- # V285.1: Investigation Table (Table UI)
- investigation_keywords = ["חקור", "חקירת", "קיצון", "אסימפטוט", "עליה", "ירידה"]
- is_investigation = "INVESTIGATION" in category or any(x in problem_text for x in investigation_keywords)
- investigation_block = ""
- if is_investigation:
- investigation_block = """
- ═══════════════════════════════════════════════════
- 📈 הוראות מיוחדות לחקירת פונקציה (Table UI):
- ═══════════════════════════════════════════════════
-
- בנוסף לשדות הרגילים ב-JSON, באחריותך להוסיף בשורש ה-JSON את השדה "investigation" המילוני הזה:
- "investigation": {
- "function": "הפונקציה ב-LaTeX",
- "derivative": "הנגזרת ב-LaTeX",
- "second_derivative": "נגזרת שנייה ב-LaTeX (השאר ריק אם אין צורך)",
- "domain": "תחום הגדרה עטוף ב-LaTeX",
- "critical_points": [
- {"x": "ערך x", "y": "ערך y", "f_prime": "0", "behavior": "מקסימום/מינימום"}
- ],
- "increasing": ["(a, b)", "(1, \\infty)"],
- "decreasing": ["(-\\infty, a)"],
- "concave_up": ["(a, b)"],
- "concave_down": ["(b, c)"]
- }
- """
- prompt = f"""
- DEPTH: {features['depth']}
- STYLE: {features['style']} (בנימה של '{g['royal']}').
- TONE: {features['tone']}
-
- {anchor_block}
- {geo_verified_block}
-
- 🎯 המשימה: פתור את התרגיל בצורה פדגוגית, מעצימה ובשלבים ברורים.
-
- 📜 כללי הפדגוגיה של BuddyMath (חובה!):
-{rules_str}
-
- {proof_block}
- {investigation_block}
-
- ═══════════════════════════════════════════════════
- הנחיות לפתרון (Solver Hint):
- {solver_hint}
- ═══════════════════════════════════════════════════
-
- השאלה לפתרון:
- {problem_text}
-
- דגימת סגנון פנייה:
- - פתיחה: "{g['example_open']}"
- - סיום: "{g['example_close']}"
- """
- return prompt
-
-
-# prompts.py - V275.1 (Safe OCR - Technique over Examples)
-def get_transcription_prompt():
- """
- V275.1: Safe OCR Prompt - Teaches TECHNIQUE, not specific examples.
-
- Why V275.1?
- - V275.0 had specific examples like "\\frac{1}{x}(a + \\frac{(\\ln x)^n}{n})"
- - This caused the LLM to "fit" different functions to the example
- - Now we teach HOW to recognize patterns, not WHAT pattern to expect
- """
- return """
- TRANSCRIBE the FULL text from the image. Include ALL Hebrew text and ALL mathematical expressions.
-
- 🎯 **YOUR ONLY JOB: Copy EXACTLY what you see. Do NOT interpret, simplify, or guess.**
-
- 📐 **TRANSCRIPTION TECHNIQUE (How to be accurate):**
-
- 1. **ZERO TRUNCATION RULE (CRITICAL - V310.0)**
- - **NEVER skip the beginning of an equation.** If a formula starts with `f(x) =`, `1/x`, or a constant, you MUST include it.
- - Look at the very left edge of the image/expression. Truncating the starting terms is a FATAL ERROR.
- - **FULL CAPTURE:** Transcribe from the first character to the last. No summaries.
-
- 2. **VISUAL DISAMBIGUATION (V310.0 - NEW)**
- - **1 vs a:** Double-check characters that look like a straight line or small loop. In fractions like `1/x` or `a/x`, verify via context.
- - **2 vs z:** Look for the horizontal base of the `2` vs the sharp angles of the `z`.
- - **0 vs o / O:** Check if it's a number (zero) or a letter (o).
- - **5 vs s / S:** Distinguish the top bar of the `5` from the curves of the `s`.
- - **MATH CONTEXT:** Parameter letters like `a, b, c` are usually distinct from constants like `1, 2, 3`. If a formula has both, look for subtle stylistic differences.
-
- 3. **FRACTION & NESTING RIGOR**
- - **WORK OUTSIDE-IN:** Identify the outermost structure (e.g., a large fraction) before transcribing the contents.
- - **Numerator/Denominator:** Transcribe EVERYTHING above and below the line. Use `\\frac{num}{den}`.
- - **Parentheses:** Use `\\left(` and `\\right)` for nested or large expressions.
-
- 4. **SCAN THE EDGES**
- - Check if the expression starts or ends very close to the Bounding Box edge. Truncation usually happens at the very start.
-
- ✅ **OUTPUT:** The EXACT text from the image, with proper LaTeX for math.
- """
-
-# ==================== V260.0 PEDAGOGICAL PROMPTS ====================
-
-def get_strategy_card_prompt(problem_text: str, data_anchor: dict) -> str:
- """V260.1: Generate high-level strategy as HINTS to encourage self-solving."""
- return f"""
- ROLE: Elite Math Tutor (Pedagogical Architect).
- TASK: Analyze this problem and provide a high-level STRATEGY guide filled with HINTS.
-
- PROBLEM:
- {problem_text}
-
- DATA (Context):
- {json.dumps(data_anchor, ensure_ascii=False)}
-
- INSTRUCTIONS:
- 1. Explain the LOGIC of how to approach this problem, but frame it as hints.
- 2. === STRATEGY CARD PEDAGOGY RULE ===
- CRITICAL: DO NOT solve the problem here. DO NOT use actual equations or numbers.
- Speak conceptually. Give the student the "blueprint" so they can try it themselves.
- End the `content` section with an encouraging call to action to try it alone first! (e.g., "קחו דף ועט, נסו לפתור לבד לפי הרמזים, ואם נתקעתם - הפתרון המלא מחכה לכם למטה!").
- 3. Provide 3-4 bullet points acting as stepping stones/hints.
- 4. CRITICAL: Output MUST be in HEBREW (עברית).
- 5. Tone: Encouraging, challenging, empowering.
-
- OUTPUT JSON:
- {{
- "title": "איך ניגשים לשאלה הזו? 💡",
- "content": "הסבר מילולי קצר המעודד עבודה עצמאית...",
- "steps": [
- "רמז 1: מכיוון שנתונה נקודת קיצון, נסו לחשוב מה זה אומר על הנגזרת...",
- "רמז 2: חיתוך עם הצירים דורש מכם..."
- ]
- }}
- """
-
-def get_visual_context_prompt(problem_text: str, category: str) -> str:
- """V260.0: Generate visual description for the sketch card."""
- return f"""
- ROLE: Visual describer for blind students / schematic generator.
- TASK: Describe the VISUAL SETUP of this math problem.
-
- PROBLEM:
- {problem_text}
-
- CATEGORY: {category}
-
- INSTRUCTIONS:
- 1. If GEOMETRY: Describe the shapes, how they connect, what is tangent to what.
- - **CRITICAL**: Populate "geometric_entities" with PRECISE COORDINATES for plotting.
- - If no coordinates are given, ESTIMATE logical coordinates (e.g., A(0,0), B(3,0) for a base).
- 2. If FUNCTION: Describe the graph shape (parabola opening up/down), intersection points, asymptotes.
- - **CRITICAL GRAPH DESCRIPTION (V300.5):** כאשר יש גרפים משורטטים בתמונה (כמו פונקציה ונגזרת), עליך לפרט ב-description את התכונות הקריטיות של כל גרף בנפרד (גרף I, גרף II וכו'). חובה לציין: האם הגרף חותך את ראשית הצירים (0,0)? האם הוא מתחיל בערך חיובי/שלילי על ציר ה-y? כמה נקודות חיתוך יש לו בערך עם ציר ה-x? האם יש לו נקודות קיצון בולטות? מידע זה קריטי כדי שהמודל הבא יוכל להתאים נכונה את המשוואות לגרפים.
- - **MULTIPLE CHOICE GRAPHS (V310.0):** בשאלות של "התאם בין גרף לפונקציה", חובה לתת תיאור מתמטי קצר (אסימפטוטות, חיתוך עם צירים) לכל אחד מהגרפים הממוספרים בתמונה בנפרד.
- 3. Goal: Help a student "see" the problem before solving.
- 4. Keep it simple and descriptive.
- 5. CRITICAL: Output MUST be in HEBREW (עברית). Explain the visuals in Hebrew.
-
- OUTPUT JSON (STRUCTURE ONLY - USE ACTUAL PROBLEM DATA):
- {{
- "title": "המחשה חזותית ✏️",
- "description": "הסבר מילולי קצר...",
- "geometric_entities": {{
- "points": [{{"label": "", "x": 0.0, "y": 0.0}}],
- "segments": [{{"start": "", "end": "", "color": "blue"}}],
- "circles": [{{"center_x": 0.0, "center_y": 0.0, "radius": 0.0}}]
- }},
- "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."
- }}
-
- 🚨 GRAPH PERSISTENCE RULES (V300.3 — CRITICAL):
- - 'latex_input' is MANDATORY. You MUST provide it. NEVER leave it as null or empty string "".
- - NEVER say "I cannot draw" or "no graph possible". Always provide your best equation.
- - For GEOMETRY: use the main circle/line equation (e.g. "(x-3)^2 + (y-5)^2 = 39").
- - For FUNCTION: use the function equation (e.g. "\\frac{{\\ln(x)}}{{x^2-4}}").
- - For LOCUS: write the final locus equation.
- - If you are TRULY unsure: use "x" as a placeholder — the server will handle it.
- - The latex_input must use valid LaTeX WITHOUT $$ wrappers (e.g. "x^2 + y^2 = 25" not "$$x^2+y^2=25$$").
-
- 🚨 HELPER SKETCH RULE (V300.3 — NEW):
- If no image is provided (blind setup):
- - Read the verbal description of the geometry/trigonometry problem carefully.
- - Extract coordinates, lines, or functions from the text to generate an illustration sketch.
- - Your goal is to CREATE the visual intuition that the student is missing.
- - Even without an image, you MUST populate "geometric_entities" and "latex_input".
- """
-
-
-# ==================== V8.6.8 MASTER PROMPT (THE ANCHOR STABILITY FIX) ====================
-
-def get_master_prompt_v860(category: str = "", problem_text: str = ""):
- """
- V8.6.9: The Anchor Stability Fix + Dynamic JSON for Graphs.
- """
- graph_field = ""
- is_graph = category == "GRAPH_IDENTIFICATION" or ("גרף" in problem_text and "איזה" in problem_text)
- if is_graph:
- graph_field = """
- "graph_analysis": {
- "function_analysis": "תיאור מתמטי מפורט של הפונקציה (נקודות חיתוך, קיצון, אסימפטוטות)",
- "graph_options": [{"id": "I", "description": "..."}, {"id": "II", "description": "..."}],
- "matching_logic": "הסבר המקשר בין תכונות הפונקציה למאפייני הגרף הנבחר",
- "final_match": "התשובה הסופית (למשל: גרף II)"
- },"""
-
- prompt_template = r"""
- 🔴 MASTER PROMPT BLOCK — VERSION V8.6.8 (THE ANCHOR STABILITY)
-
- CRITICAL: You MUST output ONLY valid JSON. Absolutely NO conversational text before or after the JSON block.
- Do NOT wrap the JSON in markdown code blocks like ```json.
-
- ROLE:
- אתה מורה פרטי למתמטיקה (הטוב ביותר בארץ!). המטרה שלך היא להסביר לתלמיד לא רק *איך* פותרים, אלא *למה* עושים כל צעד. רמת ההסבר צריכה להיות כזו שגם תתלמיד שרואה את החומר פעם ראשונה יבין 100% מהדרך. הגישה שלך היא חמה, מעודדת ומעצימה (סגנון 'הנסיך/הנסיכה').
-
- ═══════════════════════════════════════════
- ABSOLUTE RULES (violations cause immediate rejection):
- ═══════════════════════════════════════════
- 1. **DATA ANCHOR SUPREMACY (V8.9.2):** The equations in the JSON Data Anchor are the ABSOLUTE TRUTH. You MUST solve the exact math provided in the Anchor. Do NOT attempt to re-read the image for the main function; trust the pre-validated Data Anchor.
- 2. **ZERO MAGIC MATH (BABY STEPS):**
- - NEVER skip algebraic steps. Show moving sides, dividing, and expanding brackets.
- - ALWAYS explain the mathematical rule/theorem *BEFORE* applying it.
- * Bad: "נגזור ונשווה לאפס: f'(x) = 2x"
- * Good: "כדי למצוא נקודת קיצון נגזור את הפונקציה. מכיוון שזו מנה, נשתמש בכלל המנה האומר ש... נגזור את המונה בנפרד ואת המכנה בנפרד:"
- 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).
- 4. **ANTI-ASCII-ART RULE (V8.8.8):** NEVER attempt to draw or sketch a graph using text characters, keyboard symbols, slashes, or ASCII art (e.g., do not use |, /, \\, -, _ to make a picture). If asked to sketch a graph, ONLY describe its mathematical properties in text (e.g., intersections, asymptotes).
- 5. **CONTENT TYPE SEPARATION (IRON LAW - V9.1.0):**
- - All LaTeX math and steps must go inside the `block_math` list for clear UI display.
- - `block_math` MUST contain ONLY PURE MATH. Absolutely NO HEBREW LETTERS, no words, no explanations.
- - Do NOT put coordinate lists or points like `A(0,1), B(2,3)` in `block_math`. Points and their names MUST stay in `content_mixed`.
- - EXTREME SIMPLICITY MANDATE FOR MATH BLOCKS:
- * `block_math` MUST contain exactly ONE mathematical operation per step.
- * NEVER use commas (,) to separate multiple equations.
- * NEVER chain equations (e.g., NO `x = 2 + 2 = 4`). Break EVERY '=' into a new JSON step.
- * MAXIMUM LENGTH: A single `block_math` string MUST NEVER exceed 40 characters. If a calculation is longer (like a complex fraction), you MUST break it down into intermediate variables across multiple steps.
- - Descriptions and casual variables must stay in `content_mixed`.
- - NEVER write Hebrew inside `block_math` or `final_answer`. (CRITICAL: violates the algebraic validator contract).
- 6. **ANTI-TABLE RULE & EXTREMA ANALYSIS:**
- - NEVER use Markdown tables (e.g., using | and -).
- - When finding Extrema (Max/Min), DO NOT suggest using a table. Instead, use explicit text steps to test the intervals.
- - Example: "נבדוק את תחומי העלייה והירידה: עבור $x=\pi/4$ הנגזרת חיובית, ועבור $x=3\pi/4$ היא שלילית. לכן זו נקודת מקסימום".
- - You MUST complete the calculation, find the Y values ($y=f(x)$), and explicitly state the classification (Max or Min).
- 7. **STRICT JSON ONLY:** No preamble, no post-amble, no markdown.
- 7. **ANTI-PARADOX PROTOCOL (V8.6.9):**
- - If your mathematical calculation seems to lead to a paradox (e.g., length is 0, or points C and D are the exact same), DO NOT STOP THE SOLUTION.
- - 99% of the time, this is a calculation error on your end. RECALCULATE your steps using a different geometric or algebraic approach.
- - NEVER give up. NEVER output "אני מזהה סתירה בנתונים". You MUST find a valid path and provide the final answer for the student.
- 8. **LATEX SYNTAX RULE (V8.6.9 — CRITICAL):**
- - You MUST strictly use LaTeX macros for all mathematical functions.
- - You MUST write \ln (with a backslash) and NEVER just ln.
- - You MUST write \sin, \cos, \tan, \log.
- - Failure to use the backslash will crash the KaTeX renderer and Fail validation.
- 9. **ANTI-NEWLINE & BIDI SAFETY:**
- - In the `final_answer` field, NEVER use `\\` or `\newline` for line breaks.
- - The `final_answer` field MUST BE 100% PURE MATH. Absolutely NO Hebrew letters, words, or explanations. If the answer requires a textual explanation, put the text in `content_mixed` of the final step, and leave `final_answer` with just the numbers/math, or empty.
- - If there are multiple answers, separate them ONLY with English commas (e.g., "x=1, x=2").
- 10. **VARIABLE CONSISTENCY (V8.6.8):**
- - 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.
- 10. **STRATEGY CARD NO-SPOILER RULE (CRITICAL):**
- - The `strategy_card` MUST NOT contain any numbers, final equations, derivatives, or solutions.
- - It must ONLY contain high-level conceptual hints ("רמז 1: כדי למצוא קיצון, חשבו על...").
- 11. **NO HEBREW OR LOGICAL ARROWS IN LATEX:**
- - Within `block_math` and all LaTeX parts, DO NOT use Hebrew text.
- - DO NOT use logical arrows like `\implies`, `\Rightarrow`, or `\iff` as they cause parsing errors. Use descriptive words in `content_mixed` instead.
- 12. **PEDAGOGICAL HIGHLIGHTING:**
- - Use `\\color{red}{...}` for highlighting ONLY inside the `content_mixed` field when explaining inline math.
- - NEVER use color tags inside `block_math` or `final_answer` as it will crash the backend algebraic validator.
- 13. **MULTI-IMAGE & GRADING LOGIC (V308.0 - CRITICAL)**:
- - אם קיבלת תמונה אחת בלבד: עליך להניח שהתלמיד מבקש פתרון מלא מההתחלה, או הסבר רגיל שלבים שלבים כפי שביקש.
- - אם קיבלת יותר מתמונה אחת: התמונה הראשונה היא השאלה המקורית. שאר התמונות הן נסיונות הפתרון של התלמיד בכתב יד.
- - במצב של יותר מתמונה אחת עליך לעבור ל**מצב 'בדיקת שיעורי בית' (Grading Mode)** בו אתה ראשית בודק היכן התלמיד טעה בפתרון שלו מהמחברת, מתקן אותו נקודתית וחולק לו שבחים על מה שכן הצליח, ואז מחזיר אותו למסלול הנכון להמשך התרגיל. אל תוציא מיד פתרון מלא במצב זה, תן לו להבין קודם איפה הטעות!
- 14. **AI ASSESSMENT TELEMETRY (CRITICAL FOR ANALYTICS):**
- - בסיום הניתוח, עליך להוסיף אובייקט `assessment` חבוי ב-JSON.
- - בחר את ה-`primary_skill` מתוך הרשימה הסגורה הבאה בלבד: ["אלגברה", "גיאומטריה", "טריגונומטריה", "הסתברות", "חשבון דיפרנציאלי"]. בשום פנים ואופן אל תמציא נושאים חדשים.
- - `sub_skill`: תיאור חופשי קצר של תת-הנושא הספציפי בתרגיל (למשל: "חוקי חזקות").
- - `mastery_score`: ציון מ-1 עד 100 על סמך איכות הפתרון של התלמיד בתמונה. אם אין תמונת פתרון, תן ציון על פי הבנת התרגיל או שים ציון ריק.
- - `parent_note`: משפט אחד קצר וחיובי המיועד להוריו של התלמיד, המסכם את חוזקותיו (למשל: "התלמיד גילה הבנה טובה בבידוד משתנים").
- 15. **GRAPH ANTI-HALLUCINATION (CRITICAL - V310.0):**
- - When asked to match a function to a graph, NEVER rely on visual size estimation or "gut feeling" from the image.
- - You MUST find mathematical anchors: intersection points with axes ($x=0$ or $y=0$), extrema, or asymptotes.
- - Cross-reference your algebraic findings with the provided graphs.
- - If the image is ambiguous, state clearly: "בגלל שהשרטוט אינו חד-משמעי, עלינו לוודא את נכונות הגרף באמצעות הצבת נקודות חיתוך".
- 16. **CHRONOLOGICAL LOGIC & DATA ISOLATION (V8.6.9 - CRITICAL):**
- - You MUST solve sub-questions strictly in order.
- - NEVER use data, parameters (like $a=1$), or specific values that explicitly belong to a LATER sub-question (e.g., Section ב') to solve an EARLIER sub-question (e.g., Section א').
- - Solve earlier sections algebraically using general variables unless the data is part of the global question anchor.
- - If a student's finding in Section א' is required for Section ב', you may use it, but NEVER the other way around.
- 17. **VISUAL GRAPH ANALYSIS (V8.9.1):** When asked to identify a graph from an image containing multiple options (e.g., I, II, III, IV), you MUST explicitly describe what you see in the image for EACH option before making a choice. Base your final selection strictly on matching your mathematical deductions (domain, asymptotes, roots) to the visual features of the graphs in the image. Ensure these descriptions are in `content_mixed` to maintain logical transparency for the student.
- 18. **NO GUESSING RULE (V310.0):** אם הנתונים בתמונה אינם מספיקים כדי לקבוע בוודאות איזה גרף מתאים לאזו פונקציה, אל תנחש! הצג את הניתוח המתמטי והסבר מה חסר כדי להגיע להכרעה. ניתן להוסיף: "מומלץ לבחון את הגרף המצורף (אם קיים) כדי לראות את המאפיינים שחישבנו."
-
-
- ═══════════════════════════════════════════════════
- REQUIRED JSON STRUCTURE (EXACT KEYS):
- ═══════════════════════════════════════════════════
- {{
- {graph_field}
- "strategy_card": {{
- "title": "איך ניגשים לשאלה הזו? 🧭",
- "intro": "היי! נראה שיש לנו פה שאלת [נושא] מצוינת...",
- "bullets": ["רמז 1: [רמז מוכוון פעולה ללא ספוילרים או תשובות]", "רמז 2: [רמז נוסף]"],
- "call_to_action": "קחו דף ועט, נסו לפתור לבד לפי הרמזים, ואם נתקעתם – פתחו את הסעיפים למטה!"
- },
- "approach": "הקדמה מלאה: פתיח חם, אישי ומלמד שמסביר בשפה פתוחה מה נלך לעשות בתרגיל ולמה (למשל: 'כדי למצוא אסימפטוטות אנחנו נבדוק מה קורה בפלוס ומינוס אינסוף').",
- "steps": [
- {
- "step_id": 1,
- "content_mixed": "(הסבר פדגוגי מפורט ואמהי) כדי למצוא את הנגזרת נשתמש בכלל המנה, ונגזור את המונה בנפרד ואת המכנה בנפרד:",
- "block_math": "y' = \\frac{\\color{blue}{2x} \\cdot (x-1) - x^2 \\cdot \\color{blue}{1}}{(x-1)^2}"
- },
- {
- "step_id": 2,
- "content_mixed": "(המשך הסבר חם ומפורט) נציב כעת $x=\\color{red}{5}$ במשוואה שצמצמנו:",
- "block_math": "y' = \\frac{\\color{red}{5}^2 - 2\\cdot \\color{red}{5}}{(\\color{red}{5}-1)^2} = \\frac{15}{16}"
- }
- ],
- "final_answer": "התשובה הסופית נטו (LaTeX)",
- "teacher_summary": {
- "audio_pitch": "פיצ' שיחתי (כמו פודקאסט קצר) של 30-40 שניות שבו המורה מסכמת את התרגיל וחולקת תובנות. כתבי בטון אמהי, חם ומעודד.",
- "key_concepts": ["סיכום מלמד 1: הפקת לקחים ותובנה אסטרטגית מהתרגיל (למשל 'שימו לב שתמיד לפני גזירת מנה נרצה לסדר את הפונקציה')", "סיכום מלמד 2: כלל אצבע שאפשר לקחת משאלה זו הלאה"],
- "formulas": ["נוסחאות מרכזיות בהן השתמשנו, בפורמט LaTeX נקי (ללא סוגרי דולר)"]
- },
- "assessment": {
- "primary_skill": "אלגברה / גיאומטריה / טריגונומטריה / הסתברות / חשבון דיפרנציאלי",
- "sub_skill": "תת הנושא הספציפי",
- "mastery_score": 85,
- "parent_note": "משפט על הצלחת התלמיד עבור דוח ההורים."
- }
- }}
-
- CRITICAL: Output ONLY the JSON block. No text before, no text after. No markdown formatting.
- """
- return prompt_template.replace("{graph_field}", graph_field)
-
-def get_master_prompt_v430(category: str = "", problem_text: str = ""):
- """V4.3.0: Legacy placeholder, redirecting to V8.6.0 for 'The Golden Merge'"""
- return get_master_prompt_v860(category=category, problem_text=problem_text)
-
-
-# ==================== V285.0: CHECK ME PROMPT (HOMEWORK VERIFICATION) ====================
-
-def get_check_me_prompt(grade: str, student_name: str, student_gender: str = "M", data_anchor: dict = None):
- """
- V285.1: Dedicated prompt for the "Check Me" feature with DATA ANCHOR.
- The LLM acts as a homework checker, NOT a solver.
- """
- # Gender-aware phrases
- if student_gender == "F":
- g_addr = "את"
- g_did = "עשית"
- g_chose = "בחרת"
- g_forgot = "שכחת"
- g_started = "התחלת"
- g_great = "מעולה"
- g_dear = f"{student_name} יקרה"
- else:
- g_addr = "אתה"
- g_did = "עשית"
- g_chose = "בחרת"
- g_forgot = "שכחת"
- g_started = "התחלת"
- g_great = "מעולה"
- g_dear = f"{student_name} יקר"
-
- anchor_block = ""
- if data_anchor:
- anchor_block = f"""
- ══════════════════════════════════════════════════════
- 📜 DATA INTEGRITY RULE (ABSOLUTE TRUTH):
- ══════════════════════════════════════════════════════
- הנתונים להלן הם נתוני השאלה המקוריים כפי שזוהו בשלב הניתוח המוקדם.
- עליך לבדוק את פתרון התלמיד אל מול הנתונים האלו בדיוק!
- {json.dumps(data_anchor, indent=2, ensure_ascii=False)}
- ══════════════════════════════════════════════════════
- """
-
- return f"""
- 🎓 תפקיד: אתה בודקת שיעורי בית — מורה פרטית חמה שבודקת את העבודה של תלמיד.
- 🚫 אתה לא פותר את התרגיל מחדש! אתה מנתח את מה שהתלמיד כתב.
-
- 📸 היררכיית תמונות:
- 1. התמונה הראשונה (image_00) היא השאלה המקורית מהספר/מבחן.
- 2. כל שאר התמונות (image_01 ומעלה) מכילות את שלבי הפתרון שכתב התלמיד בכתב יד.
-
- 👤 התלמיד: {student_name}, כיתה {grade}.
- 👑 מגדר: {"נקבה" if student_gender == "F" else "זכר"}. השתמש/י בלשון מתאימה.
-
- {anchor_block}
-
- ═══════════════════════════════════════════════════
- 📐 שלוש שלבי הבדיקה (חובה לבצע לפי הסדר):
- ═══════════════════════════════════════════════════
-
- שלב א' — בדיקת מתודולוגיה (אסטרטגיה):
- • זהה את התרגיל מתוך התמונה.
- • בדוק: האם התלמיד בכלל בחר בשיטת פתרון נכונה?
- • למשל: האם השתמש בנוסחת השורשים כשצריך פירוק? האם גזר כשצריך אינטגרל?
- • אם השיטה שגויה מיסודה — עצור כאן. הסבר את הטעות התפיסתית בלבד.
-
- שלב ב' — אימות אלגברי צעד-אחר-צעד:
- • סרוק את שורות הפתרון שהתלמיד כתב.
- • בדוק כל מעבר: העברת אגפים, כינוס איברים, סימנים, חזקות.
- • ברגע שמזהה שבירה של חוק אלגברי — בודד את השורה המדויקת.
- • ציין מה היה צריך להיות ולמה.
-
- שלב ג' — רכיבים ויזואליים:
- • אם התלמיד צייר גרף, שרטוט גיאומטרי, או טבלת סימנים שגויים — ציין מה שגוי.
- • אם אין רכיב ויזואלי — דלג על שלב זה.
-
- ═══════════════════════════════════════════════════
- 🎯 כללי ברזל:
- ═══════════════════════════════════════════════════
- 1. אל תפתור את התרגיל מחדש! רק בדוק את מה שהתלמיד כתב.
- 2. אם הכל נכון — תן חיזוק חיובי אמיתי ומפורט.
- 3. אם יש טעות — הצבע על השורה המדויקת, הסבר מה שגוי, ומה היה צריך להיות.
- 4. טון: חם, מעודד, מקצועי. כמו מורה פרטית שבודקת מבחן עם העט האדום, אבל בלב חם.
- 5. כל התשובה בעברית.
- 6. אם אתה לא מצליח לזהות את כתב היד — ציין זאת בנימוס ובקש צילום ברור יותר.
-
- ═══════════════════════════════════════════════════
- 📋 פורמט JSON נדרש (STRICT — ללא טקסט לפני או אחרי):
- ═══════════════════════════════════════════════════
- {{
- "verdict": "correct" | "has_errors" | "methodology_error" | "unreadable",
- "score": ציון מ-0 עד 100 על העבודה (100 אם הכל נכון),
- "problem_identified": "מה התרגיל שזוהה מהתמונה (LaTeX)",
- "methodology_ok": true | false,
- "methodology_note": "הערה על השיטה שנבחרה (ריק אם הכל תקין)",
- "mistakes": [
- {{
- "mistake_description": "תיאור קצר של הטעות",
- "correction_tip": "טיפ איך לתקן"
- }}
- ],
- "feedback_steps": [
- {{
- "step_id": 1,
- "student_wrote": "מה שהתלמיד כתב בשורה זו (LaTeX)",
- "is_correct": true | false,
- "error_description": "אם שגוי: מה הטעות ולמה",
- "should_be": "מה היה צריך להיות (LaTeX, ריק אם נכון)"
- }}
- ],
- "visual_note": "הערה על שרטוט/גרף אם רלוונטי, אחרת null",
- "encouragement": "משפט חיזוק חיובי אישי ל{student_name}",
- "correct_final_answer": "התשובה הנכונה של התרגיל (LaTeX)"
- }}
-
- CRITICAL: Output ONLY the JSON block. No text before, no text after. No markdown formatting.
- """
-
-
-# ==================== V285.1: TEACHER SUMMARY PROMPT (PEDAGOGICAL) ====================
-
-def get_teacher_summary_prompt(student_name: str, student_gender: str = "M"):
- """
- V285.1: Prompt for generating a pedagogical teacher summary.
- The LLM summarizes what was learned, key concepts, formulas, and generates TTS text.
- """
- if student_gender == "F":
- g_addr = "את"
- g_learned = "למדת"
- g_solved = "פתרת"
- g_dear = student_name
- else:
- g_addr = "אתה"
- g_learned = "למדת"
- g_solved = "פתרת"
- g_dear = student_name
-
- return f"""
- אתה מורה למתמטיקה שמסכמת שיעור. קיבלת את הבעיה ואת הפתרון שנוצר.
- המשימה שלך: ליצור סיכום פדגוגי שמתמקד בנושא שנלמד, לא בשאלה הספציפית.
-
- 👤 התלמיד: {student_name}
- 👑 מגדר: {"נקבה" if student_gender == "F" else "זכר"}
-
- ═══════════════════════════════════════════════════
- 📋 מה לכלול בסיכום:
- ═══════════════════════════════════════════════════
-
- 1. topic_summary: סיכום קצר של הנושא המתמטי שעסקנו בו (לא השאלה עצמה).
- למשל: "גזירת פונקציות פולינומיאליות" או "חישוב שטח מתחת לגרף".
-
- 2. key_concepts: רשימה של 2-4 תובנות מפתח ונקודות חשובות שכדאי לזכור לתרגילים הבאים.
- למשל: "כשגוזרים חזקה, מורידים את המעריך ומחסרים 1" — כללי, לא ספציפי.
-
- 3. formulas_to_remember: רשימה של 1-3 נוסחאות גנריות שהשתמשנו בהן.
- לכתוב ב-LaTeX.
- למשל: "\\\\frac{{d}}{{dx}} x^n = n \\\\cdot x^{{n-1}}"
- אל תכלול ביטויים מספריים ספציפיים מהשאלה! רק נוסחאות כלליות.
-
- 4. tts_speech: טקסט דיבור קצר וחם למורה (4-6 משפטים).
- ⚠️ כללי ברזל ל-TTS:
- - עברית בלבד, ללא LaTeX, ללא סימנים מתמטיים, ללא אימוג'ים
- - אל תקרא לתלמיד "נסיך", "נסיכה", "גיבור", "אלוף" - רק בשם {student_name}
- - תדבר בגוף שני {"נקבה" if student_gender == "F" else "זכר"}
- - טון: חם ומקצועי, כמו מורה פרטית שמסכמת שיעור
- - ציין את הנושא שלמדנו, תובנה מרכזית אחת, ומשפט עידוד
-
- ═══════════════════════════════════════════════════
- 📋 פורמט JSON נדרש:
- ═══════════════════════════════════════════════════
- {{
- "topic_summary": "שם הנושא שנלמד (קצר)",
- "key_concepts": ["תובנה 1", "תובנה 2", "תובנה 3"],
- "formulas_to_remember": ["LaTeX נוסחה 1", "LaTeX נוסחה 2"],
- "tts_speech": "טקסט דיבור עברי נקי ל-TTS"
- }}
- """
-
-def get_anchor_validation_prompt(ocr_json: dict) -> str:
- """
- V8.9.2: Dedicated prompt for the Orchestrator's Data Anchor Validator.
- Takes the raw OCR JSON and the image to produce a syntactically perfect 'Absolute Truth'.
- """
- ocr_str = json.dumps(ocr_json, indent=2, ensure_ascii=False)
-
- return f"""
- You are a strict Mathematical Transcriber & Validator.
- Look at the provided OCR JSON and the original image.
-
- OCR JSON (Raw Extraction):
- {ocr_str}
-
- ⚠️ MISSION:
- The OCR often corrupts complex fractions, missing brackets, or mangling operators.
- Your ONLY job is to output a verified, syntactically perfect JSON containing the main mathematical functions and parameters exactly as they appear in the image.
-
- **CRITICAL VISION PROTOCOL:**
- You are a strict Visual Validator, NOT a text auto-completer. When evaluating the extracted math, you MUST cross-reference the text directly against the ORIGINAL IMAGE.
- If the input string is syntactically broken, truncated, or missing components (e.g., dropped fractions, missing multipliers outside parentheses), DO NOT delete terms to artificially 'fix' the equation.
- You MUST visually reconstruct the EXACT mathematical expression pixel-for-pixel as it appears in the image. Do not hallucinate values (e.g., changing $e$ to $\sqrt{e}$). MATCH THE PIXELS EXACTLY.
-
- RULES:
- 1. Fix any hanging operators (e.g., 'a+', 'x-').
- 2. Restore missing multipliers or fractions (e.g., if image shows '1/x' but OCR missed it).
- 3. Ensure all brackets are closed and standard variables are used.
- 4. If the OCR is correct, keep it as is.
- 5. CRITICAL: Do NOT solve the problem. Do NOT explain.
- 6. Output ONLY the corrected JSON following the exact structure of the input.
- 7. V8.9.2: If you fix a critical syntax error (like 'a+' -> 'a+1/x^2'), ensure the final result is mathematically plausible based on the visual evidence.
- 8. **HORIZONTAL VERIFICATION (V8.9.3):** Check the horizontal order of elements. If an element is to the left of a bracket in the image, it MUST be to the left of the bracket in your JSON. Do NOT let the Hebrew text flow (RTL) swap the positions of multipliers or terms.
- 9. **CRITICAL JSON STRUCTURE RULE (V8.9.4):** Every output MUST be a flat JSON dictionary with UNIQUE keys. Do NOT use duplicate keys like "equation" multiple times. If there are multiple equations, use "equation_1", "equation_2", etc.
-
- Return ONLY the corrected JSON.
- """
diff --git a/proof_graph.py b/proof_graph.py
deleted file mode 100644
index c3e995d43dd89b23933aa1fe8ac786eface63a9b..0000000000000000000000000000000000000000
--- a/proof_graph.py
+++ /dev/null
@@ -1,41 +0,0 @@
-# proof_graph.py - Immutable Mathematical Structure
-
-from pydantic import BaseModel, Field
-from typing import Optional, List, Dict, Any
-
-class ProofStep(BaseModel):
- step_id: int
- math_content: str # Immutable SymPy/LaTeX (or math_artifact)
- logic_description: str = ""
- operator_used: str = ""
- # V6 Additions:
- rule_id: str = "general_algebra"
- allowed_concepts: List[str] = Field(default_factory=list)
- complexity_score: float = 1.0
- pedagogical_tag: str = "כללי"
-
-class ProofGraph:
- def __init__(self, steps: list[ProofStep]):
- self.steps = steps
- self.is_verified = False
-
- def verify_consistency(self):
- # SymPy identity checks between steps
- pass
-
-def validate_pedagogical_legality(proof_graph: ProofGraph, curriculum_rules: dict) -> tuple[bool, str]:
- """
- V4.0: The Hard Enforcement Rule.
- Checks if any step in the proof graph uses a forbidden math operator.
- """
- forbidden = curriculum_rules.get("forbidden", [])
- reason_map = curriculum_rules.get("reason_map", {})
-
- for step in proof_graph.steps:
- if step.operator_used in forbidden:
- detailed_reason = reason_map.get(step.operator_used, step.operator_used)
- msg = f"Forbidden operator used: {detailed_reason}. Not allowed for this grade/level."
- print(f"🚨 [PEDAGOGY-GUARD] REJECTED: {msg}")
- return False, msg
-
- return True, "Solution is pedagogically legal."
diff --git a/quota_system.py b/quota_system.py
deleted file mode 100644
index c05c65b79c08cc2d72eed9b41f1a3f12f88605ec..0000000000000000000000000000000000000000
--- a/quota_system.py
+++ /dev/null
@@ -1,211 +0,0 @@
-import json
-import os
-import datetime
-from threading import Lock
-from config import IS_PRODUCTION # Added for V3.1.3 Quota Override
-
-import tempfile
-import shutil
-
-LIMITS_FILE = "student_limits.json"
-# V260.9: Use /tmp or local for usage stats to avoid permission errors
-USAGE_FILENAME = "usage_stats.json"
-
-# Function to get writable usage path
-def get_usage_file_path():
- # Try local first
- if os.access(".", os.W_OK):
- return USAGE_FILENAME
- # Fallback to temp dir
- return os.path.join(tempfile.gettempdir(), USAGE_FILENAME)
-
-USAGE_FILE = get_usage_file_path()
-
-# V261: Persistent user tracking
-ALL_USERS_FILE = "all_users.json"
-
-class QuotaManager:
- def __init__(self):
- self.lock = Lock()
- print(f"📊 [QUOTA] Using stats file: {USAGE_FILE}")
- self._load_limits()
- self._ensure_files()
-
- def _ensure_files(self):
- if not os.path.exists(LIMITS_FILE):
- try:
- with open(LIMITS_FILE, 'w', encoding='utf-8') as f:
- json.dump({"default_limit": 30, "students": {}}, f)
- except PermissionError:
- print(f"⚠️ [QUOTA] Cannot create defaults limits file (Read-only fs)")
-
- if not os.path.exists(USAGE_FILE):
- try:
- with open(USAGE_FILE, 'w', encoding='utf-8') as f:
- json.dump({}, f)
- except Exception as e:
- print(f"⚠️ [QUOTA] Failed to init usage file: {e}")
-
- # V261: Ensure persistent user list exists
- if not os.path.exists(ALL_USERS_FILE):
- try:
- with open(ALL_USERS_FILE, 'w', encoding='utf-8') as f:
- json.dump({}, f)
- print(f"📊 [QUOTA] Created {ALL_USERS_FILE}")
- except Exception as e:
- print(f"⚠️ [QUOTA] Failed to init all_users file: {e}")
-
- def _load_limits(self):
- try:
- with open(LIMITS_FILE, 'r', encoding='utf-8') as f:
- self.limits_config = json.load(f)
- except Exception as e:
- print(f"⚠️ [QUOTA] Failed to load limits: {e}")
- self.limits_config = {"default_limit": 30, "students": {}, "blocked_users": []}
-
- # V3.1.3: DEV Quota Reset
- if not IS_PRODUCTION:
- self.limits_config["default_limit"] = 1000
-
- def _load_usage(self):
- try:
- with open(USAGE_FILE, 'r', encoding='utf-8') as f:
- return json.load(f)
- except Exception:
- return {}
-
- def _save_usage(self, usage_data):
- try:
- with open(USAGE_FILE, 'w', encoding='utf-8') as f:
- json.dump(usage_data, f, indent=2)
- except Exception as e:
- print(f"⚠️ [QUOTA] Failed to save usage: {e}")
-
- def _load_all_users(self):
- try:
- with open(ALL_USERS_FILE, 'r', encoding='utf-8') as f:
- return json.load(f)
- except:
- return {}
-
- def _save_all_users(self, data):
- try:
- with open(ALL_USERS_FILE, 'w', encoding='utf-8') as f:
- json.dump(data, f, indent=2)
- except Exception as e:
- print(f"⚠️ [QUOTA] Failed to save all_users: {e}")
-
- def get_today_key(self):
- return datetime.date.today().isoformat()
-
- def track_user(self, student_name):
- """V261: Updates the last_seen for a user in persistent storage"""
- if not student_name: return
- try:
- with self.lock:
- users = self._load_all_users()
- users[student_name] = {
- "last_seen": datetime.datetime.now().isoformat(),
- "id": student_name
- }
- self._save_all_users(users)
- except Exception as e:
- print(f"⚠️ [QUOTA] Failed to track user: {e}")
-
- def check_limit(self, student_name):
- """
- Returns:
- (allowed: bool, message: str, current_usage: int, limit: int)
- """
- if not student_name:
- return True, "No name", 0, 9999
-
- # V261: Track user on every check
- self.track_user(student_name)
-
- # Refresh limits config in case it changed manually
- self._load_limits()
-
- if student_name in self.limits_config.get("blocked_users", []):
- return False, "User is blocked", 0, 0
-
- limit = self.limits_config.get("students", {}).get(student_name, self.limits_config.get("default_limit", 30))
-
- # Admin override or high limit
- if limit < 0:
- return True, "Unlimited", 0, -1
-
- today = self.get_today_key()
- with self.lock:
- usage_data = self._load_usage()
- today_usage = usage_data.get(today, {})
- current_count = today_usage.get(student_name, 0)
-
- if current_count >= limit:
- return False, f"Daily limit reached ({limit})", current_count, limit
-
- return True, "Allowed", current_count, limit
-
- def increment_usage(self, student_name):
- if not student_name:
- return
-
- today = self.get_today_key()
- with self.lock:
- usage_data = self._load_usage()
- if today not in usage_data:
- usage_data[today] = {}
-
- usage_data[today][student_name] = usage_data[today].get(student_name, 0) + 1
- self._save_usage(usage_data)
-
- print(f"📊 [QUOTA] Incremented for {student_name}. Date: {today}")
-
- # ================= ADMIN METHODS (V261) =================
-
- def get_all_users_data(self):
- """Merges all_users (history), limits (config), and usage (today)"""
- all_users = self._load_all_users() # {id: {last_seen...}}
- limits = self.limits_config
- today_usage = self._load_usage().get(self.get_today_key(), {})
-
- result = []
- # Merge known users from history
- for uid, udata in all_users.items():
- limit = limits.get("students", {}).get(uid, limits.get("default_limit", 30))
- usage = today_usage.get(uid, 0)
- result.append({
- "id": uid,
- "last_seen": udata.get("last_seen"),
- "limit": limit,
- "usage_today": usage,
- "is_blocked": uid in limits.get("blocked_users", [])
- })
-
- # Also include users in limits config who might not be in history yet (?)
- # (Optional, skipping for now to keep it simple)
-
- # Sort by most recently seen
- result.sort(key=lambda x: x.get("last_seen", ""), reverse=True)
- return result
-
- def set_user_limit(self, user_id, new_limit):
- """Updates the limit for a specific user"""
- with self.lock:
- self._load_limits()
- if "students" not in self.limits_config:
- self.limits_config["students"] = {}
-
- try:
- limit_int = int(new_limit)
- self.limits_config["students"][user_id] = limit_int
-
- # Save back to file
- with open(LIMITS_FILE, 'w', encoding='utf-8') as f:
- json.dump(self.limits_config, f, indent=4)
- return True, "Updated"
- except Exception as e:
- return False, str(e)
-
-# Global instance
-quota_manager = QuotaManager()
diff --git a/quota_system_v2.py b/quota_system_v2.py
deleted file mode 100644
index 3b499eb220fdf5d959818eb960ed38450db6a02e..0000000000000000000000000000000000000000
--- a/quota_system_v2.py
+++ /dev/null
@@ -1,272 +0,0 @@
-import datetime
-import logging
-from firebase_admin import firestore
-
-# We import the manager to get the initialized DB
-from firebase_manager import firebase_manager
-
-logger = logging.getLogger("HamoraServer")
-
-class QuotaManagerV2:
- """
- V2 QuotaManager: Uses Firestore exclusively.
- Ignores local JSON files.
- """
-
- def __init__(self):
- self.default_limit = 30 # Can be overridden in config or by user doc
- self.collection_name = "users"
-
- def _get_db(self):
- return firebase_manager.get_db()
-
- def get_today_key(self):
- return datetime.date.today().isoformat()
-
- def get_this_month_key(self):
- return datetime.date.today().strftime("%Y-%m")
-
- def check_limit(self, uid: str, device_id: str = None):
- """
- Returns:
- (allowed: bool, message: str, current_usage: int, limit: int)
- """
- if not uid:
- return False, "Missing UID", 0, 0
-
- db = self._get_db()
- if not db:
- logger.error("❌ [QUOTA_V2] Firestore DB not available.")
- return False, "Database error", 0, 0
-
- try:
- doc_ref = db.collection(self.collection_name).document(uid)
- doc = doc_ref.get()
-
- if not doc.exists:
- logger.warning(f"⚠️ [QUOTA_V2] User document not found for {uid}. Allowing with default.")
- return True, "Allowed (Default)", 0, self.default_limit
-
- data = doc.to_dict()
-
- # Check if blocked
- if data.get("status") == "rejected":
- return False, "User is blocked", 0, 0
-
- # Admin or special roles
- if data.get("role") == "admin" or data.get("status") == "admin" or data.get("isAdmin") is True or data.get("tier") == "admin_unlimited" or data.get("is_unlimited") is True:
- return True, "Unlimited (Admin/VIP)", 0, -1
-
- # --- Device ID Enforcement ---
- if device_id:
- tier = data.get("tier", "student_basic")
- allowed_devices = data.get("allowed_devices", [])
-
- max_devices = 2 if tier == "parent_premium" else 1
-
- if device_id not in allowed_devices:
- if len(allowed_devices) >= max_devices:
- return False, f"Device Limit Exceeded ({max_devices})", 0, 0
- else:
- # Add device atomically
- doc_ref.update({"allowed_devices": firestore.ArrayUnion([device_id])})
- # -----------------------------
-
- limit = data.get("quota_limit", self.default_limit)
- monthly_token_budget = data.get("monthly_token_budget")
-
- # Special limits
- if limit < 0:
- return True, "Unlimited", 0, -1
-
- today = self.get_today_key()
- this_month = self.get_this_month_key()
-
- last_usage_date = data.get("last_usage_date", "")
- last_usage_month = data.get("last_usage_month", "")
-
- # Daily Question Quota
- if last_usage_date != today:
- current_count = 0
- else:
- current_count = data.get("used_today", 0)
-
- if current_count >= limit:
- return False, f"Daily limit reached ({limit})", current_count, limit
-
- # Monthly Token Quota (if defined)
- if monthly_token_budget is not None:
- if last_usage_month != this_month:
- used_tokens = 0
- else:
- used_tokens = data.get("used_tokens_this_month", 0)
-
- if used_tokens >= monthly_token_budget:
- return False, f"Monthly token budget reached", current_count, limit
-
- return True, "Allowed", current_count, limit
-
- except Exception as e:
- logger.error(f"❌ [QUOTA_V2] Error checking limit for {uid}: {e}")
- # Fail closed or open? Let's fail open temporarily so we don't block users if DB hiccups
- return True, "Error - Fallback Allow", 0, self.default_limit
-
- def check_wallet(self, uid: str, min_required: int = 2000):
- """
- V5.15.0: Strict token balance check for Pencil Economy (Pre-flight).
- 1 Pencil = 17,000 tokens.
- """
- if not uid or uid == "dev-bypass-user":
- return True, 1000000
-
- db = self._get_db()
- if not db:
- return False, 0
-
- try:
- doc_ref = db.collection(self.collection_name).document(uid)
- doc = doc_ref.get()
- if not doc.exists:
- return False, 0
-
- data = doc.to_dict()
-
- # Admin Bypass
- if data.get("role") == "admin" or data.get("status") == "admin" or data.get("isAdmin") is True or data.get("tier") == "admin_unlimited" or data.get("is_unlimited") is True:
- return True, 1000000
-
- wallet = data.get('wallet', {})
- token_balance = wallet.get('token_balance', 0)
-
- # Strict check against min_required
- return token_balance >= min_required, token_balance
- except Exception as e:
- logger.error(f"❌ [QUOTA_V2] Failed to check wallet for {uid}: {e}")
- return True, 0
-
- def add_tokens_with_absorption(self, uid: str, tokens_to_add: int):
- """
- V5.15.0: Add tokens with "Debt Absorption" and "Total Purchased" logic.
- """
- db = self._get_db()
- if not db:
- return
-
- try:
- doc_ref = db.collection(self.collection_name).document(uid)
- @firestore.transactional
- def update_in_transaction(transaction, doc_ref):
- snapshot = doc_ref.get(transaction=transaction)
- current_balance = 0
- if snapshot.exists:
- current_balance = snapshot.to_dict().get('wallet', {}).get('token_balance', 0)
-
- # Absorption logic
- starting_point = max(0, current_balance)
- new_balance = starting_point + tokens_to_add
-
- # Update both balance and total purchased (capacity)
- transaction.update(doc_ref, {
- "wallet.token_balance": new_balance,
- "wallet.total_purchased_tokens": firestore.Increment(tokens_to_add)
- })
-
- transaction = db.transaction()
- update_in_transaction(transaction, doc_ref)
- logger.info(f"💰 [QUOTA_V2] Added {tokens_to_add} tokens to {uid}. Total capacity increased.")
- except Exception as e:
- logger.error(f"❌ [QUOTA_V2] Failed to add tokens with absorption for {uid}: {e}")
-
- def increment_usage(self, uid: str, increment_questions: int = 1, tokens_used: int = 0):
- """
- V5.15.0: Atomic increment with Overdraft Prevention.
- """
- if not uid:
- return
-
- db = self._get_db()
- if not db:
- return
-
- today = self.get_today_key()
- this_month = self.get_this_month_key()
- doc_ref = db.collection(self.collection_name).document(uid)
-
- try:
- @firestore.transactional
- def update_in_transaction(transaction, doc_ref):
- snapshot = doc_ref.get(transaction=transaction)
- if not snapshot.exists:
- if tokens_used > 0:
- raise ValueError("Insufficient Pencils: User document missing")
- transaction.set(doc_ref, {
- "quota_limit": self.default_limit,
- "used_today": increment_questions,
- "used_tokens_this_month": tokens_used,
- "last_usage_date": today,
- "last_usage_month": this_month,
- "last_seen": firestore.SERVER_TIMESTAMP,
- "wallet": {"token_balance": 0, "total_purchased_tokens": 0}
- })
- return
-
- data = snapshot.to_dict()
-
- # V5.15.0: STRICT OVERDRAFT PREVENTION
- if tokens_used > 0:
- wallet = data.get('wallet', {})
- current_balance = wallet.get('token_balance', 0)
- if current_balance < tokens_used:
- logger.warning(f"🚫 [QUOTA_V2] Overdraft blocked for {uid}: {current_balance} < {tokens_used}")
- raise ValueError("Insufficient Pencils")
-
- last_date = data.get("last_usage_date", "")
- last_month = data.get("last_usage_month", "")
-
- # Daily logic
- if last_date != today:
- new_usage = increment_questions
- else:
- new_usage = data.get("used_today", 0) + increment_questions
-
- # Monthly Token logic
- if last_month != this_month:
- new_tokens = tokens_used
- else:
- new_tokens = data.get("used_tokens_this_month", 0) + tokens_used
-
- # V5.9.8: Lifetime Tracking & Economics
- total_used = data.get("total_tokens_used", 0) + tokens_used
-
- from cost_tracker import PRICING
- avg_price = (PRICING["input"] + PRICING["output"]) / 2
- cost_delta = (tokens_used / 1e6) * avg_price
- total_cost = data.get("total_cost_usd", 0.0) + cost_delta
- total_exercises = data.get("total_exercises_solved", 0) + increment_questions
-
- transaction.update(doc_ref, {
- "used_today": new_usage,
- "used_tokens_this_month": new_tokens,
- "total_tokens_used": total_used,
- "total_exercises_solved": total_exercises,
- "total_cost_usd": total_cost,
- "last_usage_date": today,
- "last_active_date": today,
- "last_usage_month": this_month,
- "last_seen": firestore.SERVER_TIMESTAMP,
- "wallet.token_balance": firestore.Increment(-tokens_used)
- })
-
- transaction = db.transaction()
- update_in_transaction(transaction, doc_ref)
- logger.info(f"📊 [QUOTA_V2] Incremented usage for {uid}. Tokens deducted: {tokens_used}")
-
- except ValueError as ve:
- # Re-raise for main.py to catch
- raise ve
- except Exception as e:
- logger.error(f"❌ [QUOTA_V2] Failed to increment usage for {uid}: {e}")
- raise e
-
-# Global instance
-quota_manager_v2 = QuotaManagerV2()
diff --git a/refiner.py b/refiner.py
deleted file mode 100644
index add1a81332e7773de96bfa60b7182f6fd9157e12..0000000000000000000000000000000000000000
--- a/refiner.py
+++ /dev/null
@@ -1,31 +0,0 @@
-import re
-import logging
-
-logger = logging.getLogger("Refiner")
-
-class Refiner:
- @staticmethod
- def refine(data: dict) -> dict:
- try:
- if 'sections' in data and isinstance(data['sections'], list):
- for section in data['sections']:
- if 'steps' in section and isinstance(section['steps'], list):
- for step in section['steps']:
- # חשוב: אנחנו מנקים רק את block_math מדולרים
- # את content_mixed אנחנו משאירים עם דולרים כדי שהאפליקציה תרנדר!
- if 'block_math' in step:
- step['block_math'] = Refiner._clean_latex_block(step['block_math'])
-
- return data
- except Exception as e:
- logger.error(f"Refinement error: {e}")
- return data
-
- @staticmethod
- def _clean_latex_block(tex: str) -> str:
- if not tex or tex == 'null': return ""
- s = str(tex)
- # בבלוק מתמטי נקי לא צריך דולרים
- s = s.replace('$$', '').replace('$', '')
- s = s.replace('-', '−') # מינוס יפה
- return s.strip()
\ No newline at end of file
diff --git a/report_aggregator.py b/report_aggregator.py
deleted file mode 100644
index 334e90a5f4b5765ff40ac0316e3b9dc250d2688a..0000000000000000000000000000000000000000
--- a/report_aggregator.py
+++ /dev/null
@@ -1,70 +0,0 @@
-# buddy_math_server/report_aggregator.py
-import time
-import logging
-from collections import defaultdict
-from firebase_manager import firebase_manager
-
-logger = logging.getLogger(__name__)
-
-class ReportAggregator:
- def __init__(self):
- self.db = firebase_manager.get_db()
-
- def get_summary_for_period(self, uid: str, days=7):
- """
- Aggregates metrics for the last 7 days.
- """
- if not self.db:
- return None
-
- start_time = time.time() - (days * 24 * 60 * 60)
-
- summary = {
- "total_interactions": 0,
- "mastery_by_category": defaultdict(list),
- "completed_challenges": 0,
- "teacher_notes": [],
- "top_categories": set()
- }
-
- try:
- # 1. Query Analytics
- analytics_docs = self.db.collection('users').document(uid).collection('analytics') \
- .where('timestamp', '>', start_time).stream()
-
- for doc in analytics_docs:
- data = doc.to_dict()
- summary["total_interactions"] += 1
-
- cat = data.get('category', 'כללי')
- score = data.get('mastery_score')
- if score is not None:
- summary["mastery_by_category"][cat].append(score)
- summary["top_categories"].add(cat)
-
- note = data.get('parent_note') or data.get('teacher_summary')
- if note:
- summary["teacher_notes"].append(note)
-
- # 2. Query Completed Suggestions (Challenges)
- suggestions = self.db.collection('users').document(uid).collection('suggestions') \
- .where('status', '==', 'completed') \
- .where('created_at', '>', start_time).stream()
-
- for sug in suggestions:
- summary["completed_challenges"] += 1
-
- # 3. Calculate Averages
- avg_mastery = {}
- for cat, scores in summary["mastery_by_category"].items():
- avg_mastery[cat] = sum(scores) / len(scores)
-
- summary["avg_mastery"] = avg_mastery
-
- return summary
-
- except Exception as e:
- logger.error(f"❌ [AGGREGATOR] Error: {e}")
- return None
-
-report_aggregator = ReportAggregator()
diff --git a/report_delivery_service.py b/report_delivery_service.py
deleted file mode 100644
index 493107d96a4b2163259e73f60e5a26cf0582585c..0000000000000000000000000000000000000000
--- a/report_delivery_service.py
+++ /dev/null
@@ -1,78 +0,0 @@
-# buddy_math_server/report_delivery_service.py
-import os
-import logging
-from jinja2 import Template
-from sendgrid import SendGridAPIClient
-from sendgrid.helpers.mail import Mail, Attachment, FileContent, FileName, FileType, Disposition
-import base64
-
-logger = logging.getLogger(__name__)
-
-class ReportDeliveryService:
- def __init__(self):
- self.api_key = os.environ.get('SENDGRID_API_KEY')
- self.from_email = 'tutor@buddy-math.com'
-
- def render_html(self, template_path, data):
- """
- Renders the Jinja2 template with data.
- """
- with open(template_path, 'r', encoding='utf-8') as f:
- template_content = f.read()
- template = Template(template_content)
- return template.render(data)
-
- def generate_pdf(self, html_content, output_path):
- """
- Converts HTML to PDF.
- Note: Requires xhtml2pdf or pdfkit.
- Using xhtml2pdf for simplicity in pure python environments.
- """
- try:
- from xhtml2pdf import pisa
- with open(output_path, "wb") as f:
- pisa_status = pisa.CreatePDF(html_content, dest=f)
- return not pisa_status.err
- except ImportError:
- logger.error("❌ [DELIVERY] xhtml2pdf not installed. Saving as HTML instead.")
- with open(output_path.replace('.pdf', '.html'), 'w', encoding='utf-8') as f:
- f.write(html_content)
- return False
-
- def send_email_with_attachment(self, to_email, subject, body, attachment_path, student_name):
- """
- Sends an email via SendGrid with the report attached.
- """
- if not self.api_key:
- logger.error("❌ [DELIVERY] SENDGRID_API_KEY missing.")
- return False
-
- message = Mail(
- from_email=self.from_email,
- to_emails=to_email,
- subject=subject,
- html_content=body
- )
-
- try:
- with open(attachment_path, 'rb') as f:
- data = f.read()
- encoded_file = base64.b64encode(data).decode()
-
- attachedFile = Attachment(
- FileContent(encoded_file),
- FileName(f'BuddyMath_Report_{student_name}.pdf'),
- FileType('application/pdf'),
- Disposition('attachment')
- )
- message.add_attachment(attachedFile)
-
- sg = SendGridAPIClient(self.api_key)
- response = sg.send(message)
- logger.info(f"🚀 [DELIVERY] Email sent to {to_email}. Status: {response.status_code}")
- return True
- except Exception as e:
- logger.error(f"❌ [DELIVERY] SendGrid Error: {e}")
- return False
-
-report_delivery_service = ReportDeliveryService()
diff --git a/report_generator.py b/report_generator.py
deleted file mode 100644
index a14bc87c86ccf9da9083a34cf590946c743d990b..0000000000000000000000000000000000000000
--- a/report_generator.py
+++ /dev/null
@@ -1,74 +0,0 @@
-# buddy_math_server/report_generator.py
-import json
-import logging
-import requests
-import google.generativeai as genai
-from config import GEMINI_MODEL
-from report_aggregator import report_aggregator
-
-logger = logging.getLogger(__name__)
-
-class ReportGenerator:
- def __init__(self):
- self.model = genai.GenerativeModel(GEMINI_MODEL)
-
- async def generate_ai_summary(self, data_summary: dict, student_name: str) -> str:
- """
- Uses Gemini 2.0 to generate a warm pedagogical summary for the parent.
- """
- # Prepare the data string for the prompt
- mastery_str = ", ".join([f"{cat}: {score:.1f}%" for cat, score in data_summary.get("avg_mastery", {}).items()])
-
- prompt = f"""בשם 'המורה הפרטית' של Buddy-Math, כתוב סיכום שבועי להורה של {student_name}.
-נתוני הלמידה השבוע:
-- סה"כ תרגילים שבוצעו: {data_summary['total_interactions']}
-- ציון ממוצע לפי נושא: {mastery_str}
-- אתגרים אקטיביים שהושלמו: {data_summary['completed_challenges']}
-- הערות מורה שנאספו: {"; ".join(data_summary['teacher_notes'][:5])}
-
-הוראות:
-1. כתוב פסקה קצרה (3-4 שורות) בעברית חמה, מעודדת ומקצועית.
-2. הדגש נושאים שבהם התלמיד השתפר והפגנת התמדה.
-3. הוסף טיפ פדגוגי אחד קטן להמשך.
-4. אל תשתמש בפורמט של מכתב רשמי, אלא בפנייה ישירה וחמה.
-"""
- try:
- res = await self.model.generate_content_async(prompt)
- return res.text.strip()
- except Exception as e:
- logger.error(f"❌ [REPORT-GEN] AI Summary Error: {e}")
- return "התלמיד התקדם יפה השבוע במתמטיקה והפגין סקרנות רבה."
-
- def generate_radar_chart_url(self, avg_mastery: dict) -> str:
- """
- Generates a QuickChart URL for a Radar Chart.
- """
- labels = list(avg_mastery.keys())
- values = list(avg_mastery.values())
-
- if not labels:
- return "https://quickchart.io/chart?c={type:'radar',data:{labels:['כללי'],datasets:[{label:'התקדמות',data:[0]}]}}"
-
- chart_config = {
- "type": "radar",
- "data": {
- "labels": labels,
- "datasets": [{
- "label": "רמת שליטה (%)",
- "data": values,
- "backgroundColor": "rgba(33, 150, 243, 0.2)",
- "borderColor": "rgb(33, 150, 243)",
- "pointBackgroundColor": "rgb(33, 150, 243)"
- }]
- },
- "options": {
- "scale": {
- "ticks": {"beginAtZero": True, "max": 100}
- }
- }
- }
-
- url = f"https://quickchart.io/chart?c={json.dumps(chart_config)}"
- return url
-
-report_generator = ReportGenerator()
diff --git a/report_template.html b/report_template.html
deleted file mode 100644
index afecea7cd09e252b2dbabe7eb0b649ea28c1c281..0000000000000000000000000000000000000000
--- a/report_template.html
+++ /dev/null
@@ -1,58 +0,0 @@
-
-
-
-
- דו"ח למידה שבועי - Buddy-Math
-
-
-
-
-
-
-
-
-
{{ total_interactions }}
-
אינטראקציות השבוע
-
-
-
{{ completed_challenges }}
-
אתגרים שהושלמו
-
-
-
{{ avg_mastery_pct | round(1) }}%
-
ממוצע שליטה כללי
-
-
-
-
-
דבר המורה:
-
{{ ai_summary }}
-
-
-
-
מפת המיומנויות של {{ student_name }}
-

-
-
-
-
-
-
diff --git a/requirements.txt b/requirements.txt
index 7513e1903cb1edf0445b1008bf694bae40a17cfe..979c0ca7938ea36b5a258e8b221084ce87e4cf8c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,18 +1,2 @@
-fastapi
-uvicorn
-python-multipart
-google-generativeai==0.8.3
-pillow
-python-dotenv
-json_repair
-matplotlib
-numpy
-sympy
-edge-tts>=7.0.0
-firebase-admin==6.4.0
-opencv-python-headless
-sse-starlette
-jinja2
-pdfkit
-weasyprint
-sendgrid
\ No newline at end of file
+llama-cpp-python[server]==0.2.90
+huggingface_hub
diff --git a/sample_report.html b/sample_report.html
deleted file mode 100644
index 19569c659cea8e1c11749596831655e4e98ce0ed..0000000000000000000000000000000000000000
--- a/sample_report.html
+++ /dev/null
@@ -1,266 +0,0 @@
-
-
-
-
- דוח התקדמות שבועי - BuddyMath
-
-
-
-
-
-
-
-
-
סה"כ תרגילים שנפתרו
-
14
-
-
-
ממוצע שליטה כללי
-
75%
-
-
-
-
-
-
-
מפת מיומנויות
-

-
-
-
-
פירוט נושאים
-
-
-
-
-
- תתי-נושאים שטופלו: משוואות ממעלה ראשונה, חוקי חזקות
-
-
-
-
-
-
-
- תתי-נושאים שטופלו: חפיפת משולשים
-
-
-
-
-
-
-
-
-
נקודות לשימור ולחיזוק 💡
-
-
התלמיד גילה הבנה טובה בבידוד משתנים
-
-
שליטה מעולה בחוקי חזקות!
-
-
-
-
-
-
-
מטרה לשבוע הבא 🎯
-
חזרה על נוסחאות הכפל המקוצר לשיפור הדיוק האלגברי.
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/smart_solver.py b/smart_solver.py
deleted file mode 100644
index 06f5193c5e465588da59d818d437da58f345e036..0000000000000000000000000000000000000000
--- a/smart_solver.py
+++ /dev/null
@@ -1,714 +0,0 @@
-# smart_solver.py - V7.4 (TYPED SIGNED STEPS + DOMAIN-AWARE CONTRACTS)
-import re
-import hashlib
-import logging
-import sympy
-from sympy import symbols, Eq, solve, sympify, diff, latex, Symbol, simplify, trigsimp
-from sympy import srepr, default_sort_key
-from pydantic import BaseModel, Field
-from typing import List, Dict, Any, Tuple, Union, Optional
-from dataclasses import dataclass
-import copy
-from domain.step_types import StepType, SignedStep
-
-from utils.math_utils import aggressive_sympy_sanitizer
-
-logger = logging.getLogger(__name__)
-
-# ==================== V7.3: TYPED ACTION CONTRACT ====================
-
-@dataclass
-class ActionContext:
- """
- V7.3: Typed contract between Orchestrator and Solver.
- Replaces generic dict — unknown fields cause AttributeError, not silent bugs.
- Add new fields here as the system learns new problem types.
- """
- center: Optional[Tuple[float, float]] = None
- radius: Optional[float] = None
- point_a: Optional[Tuple[float, float]] = None
- # Future: triangle_vertices, line_coefficients, etc.
-
-
-# ==================== V7.2: DETERMINISTIC SIGNATURE ENGINE ====================
-
-def sign_step(expression: Union[str, list], problem_id: str, step_id: str) -> SignedStep:
- """
- V7.4: Creates a SHA256-signed algebraic step as a typed SignedStep.
- Uses sympy.srepr for canonical representation to guarantee deterministic hashing.
- Sorts list results to ensure hash stability regardless of SymPy output order.
-
- step_type = ALGEBRAIC — ConsistencyGate will validate via sympy.sympify.
- payload = raw expression list or string (semantic truth for validator).
- expression = display string injected into renderer placeholders.
- """
- if isinstance(expression, list):
- # Sort for canonical order (critical for hash determinism)
- parsed_exprs = sorted(
- [sympy.sympify(e) for e in expression],
- key=default_sort_key
- )
- canonical = str([srepr(e) for e in parsed_exprs])
- expr_str = " OR ".join(str(e) for e in parsed_exprs)
- payload = [str(e) for e in parsed_exprs]
- else:
- parsed = sympy.sympify(expression)
- canonical = srepr(parsed)
- expr_str = str(parsed)
- payload = expr_str
-
- raw = f"{canonical}{problem_id}{step_id}"
- sig = hashlib.sha256(raw.encode()).hexdigest()
-
- logger.info(f"[SIGNATURE] Signed step '{step_id}': hash={sig[:12]}...")
- return SignedStep(
- id=step_id,
- expression=expr_str,
- payload=payload,
- step_type=StepType.ALGEBRAIC,
- hash=sig,
- )
-
-
-def sign_step_geometry(labels: list, problem_id: str, step_id: str) -> SignedStep:
- """
- V7.4: Typed signer for geometry results (strings, not SymPy expressions).
-
- step_type = GEOMETRY — ConsistencyGate validates structurally, NOT via sympify.
- payload = original labels list (semantic truth: list[str] of points/distances).
- expression = display string for renderer injection (pipe-separated, human-readable).
-
- CTO note: expression is display-only. payload is the authoritative structure
- the validator uses to verify integrity. Never pass expression to sympy.
- """
- canonical = str(sorted(str(l) for l in labels))
- sig = hashlib.sha256(f"{canonical}{problem_id}{step_id}".encode()).hexdigest()
- expr_str = " | ".join(str(l) for l in labels)
- logger.info(f"[SIGNATURE] Signed geometry step '{step_id}': hash={sig[:12]}...")
- return SignedStep(
- id=step_id,
- expression=expr_str,
- payload=list(labels),
- step_type=StepType.GEOMETRY,
- hash=sig,
- )
-
-
-def resolve_ast_target(target_step_ref: str, ast_registry: dict) -> str:
- """
- V7.2: Looks up the actual math expression for an AST node ID.
- The Planner works with IDs only — this is where IDs are resolved to real math.
- Raises KeyError if the reference is invalid, preventing silent hallucination.
- """
- if target_step_ref not in ast_registry:
- raise KeyError(
- f"[RESOLVER] AST node '{target_step_ref}' not found in registry. "
- f"Known nodes: {list(ast_registry.keys())}"
- )
- return ast_registry[target_step_ref]
-
-
-# ==================== V7.2.1: DETERMINISTIC SOLVER DISPATCHER ====================
-
-def execute_action(
- action: str,
- expression: str,
- problem_id: str,
- step_id: str,
- ast_variables: list = None,
- context: Optional[ActionContext] = None
-) -> dict:
- """
- V7.2.1 Wall 2: Deterministic Math Engine.
-
- Routes a Planner Enum command to SymPy, executes it deterministically,
- and returns a SHA256-signed step result.
-
- Multi-variable handling (CTO note): if the expression has multiple free
- symbols, we solve for the first variable listed in `ast_variables` (from
- AST metadata). If no list is provided, we default to the first free symbol
- found by SymPy — never guess blindly.
-
- Args:
- action: One of ComputeAction enum values (string)
- expression: Raw math expression string from AST registry
- problem_id: For hash seeding
- step_id: For hash seeding and tracking
- ast_variables: Ordered list of variable names from build_ast_metadata()
-
- Returns:
- dict: {"id": step_id, "hash": "sha256...", "expression": "..."}
- """
- import sympy as sp
-
- # Parse expression — treat '=' as LHS - RHS for sp.solve
- normalized = expression.replace('=', '-')
- try:
- expr = sp.sympify(normalized, evaluate=False)
- except Exception as e:
- raise ValueError(f"[SOLVER] Cannot parse expression '{expression}': {e}")
-
- # Multi-variable: determine which symbol to solve for
- free_syms = list(expr.free_symbols)
- solve_for = None
- if action == "SOLVE_EQUATION":
- if ast_variables:
- # Use the first AST-declared variable that is actually in the expression
- for var_name in ast_variables:
- candidate = sp.Symbol(var_name)
- if candidate in free_syms:
- solve_for = candidate
- break
- if solve_for is None and free_syms:
- # Fallback: first free symbol (alphabetically for determinism)
- solve_for = sorted(free_syms, key=lambda s: s.name)[0]
- logger.warning(
- f"[SOLVER] No ast_variables hint provided. "
- f"Solving for '{solve_for}' (first free symbol in expression)."
- )
-
- # ── V7.3: Geometry Guards ──────────────────────────────────────────────────
- if action == "CALCULATE_DISTANCE" and (
- not context or not context.center or not context.point_a
- ):
- raise ValueError(
- "MissingContextError: CALCULATE_DISTANCE requires context.center and context.point_a"
- )
-
- if action == "CALCULATE_SLOPE_AND_LINE" and (
- not context or not context.center or not context.point_a
- ):
- raise ValueError(
- "MissingContextError: CALCULATE_SLOPE_AND_LINE requires context.center and context.point_a"
- )
-
- # ── V7.3: Geometry Handler Dispatch ───────────────────────────────────────
- if action == "FIND_AXIS_INTERSECTIONS":
- # Substitute x=0 → solve for y, then y=0 → solve for x
- x_sym, y_sym = sp.Symbol('x'), sp.Symbol('y')
- y_intercepts = sp.solve(expr.subs(x_sym, 0), y_sym)
- x_intercepts = sp.solve(expr.subs(y_sym, 0), x_sym)
- # Build human-readable result list
- result_parts = []
- for yi in y_intercepts:
- result_parts.append(f"(0, {yi})") # x=0
- for xi in x_intercepts:
- result_parts.append(f"({xi}, 0)") # y=0
- result = result_parts if result_parts else ["אין נקודות חיתוך עם הצירים"]
- logger.info(f"[SOLVER] ✅ FIND_AXIS_INTERSECTIONS on '{expression}' → {result}")
- return sign_step_geometry(result, problem_id, step_id)
-
- elif action == "CALCULATE_SLOPE_AND_LINE":
- cx, cy = context.center # e.g. (3, 4)
- px, py = context.point_a # e.g. (0, 0) — origin
- # Vertical line guard
- if px == cx:
- line_eq = f"x = {cx}"
- result = [line_eq]
- else:
- slope = sp.Rational(cy - py, cx - px) # exact fraction (no float)
- # y - py = slope*(x - px) → y = slope*x + b
- b = py - slope * px
- x_sym = sp.Symbol('x')
- line_expr = slope * x_sym + b
- # Simplify and return LaTeX-friendly string
- result = [str(sp.simplify(line_expr))]
- logger.info(f"[SOLVER] ✅ CALCULATE_SLOPE_AND_LINE center={context.center} → {result}")
- return sign_step_geometry(result, problem_id, step_id)
-
- elif action == "CALCULATE_DISTANCE":
- cx, cy = context.center
- px, py = context.point_a
- # Use exact Rational arithmetic to avoid float comparison ambiguity
- cx_r, cy_r = sp.Rational(cx).limit_denominator(1000), sp.Rational(cy).limit_denominator(1000)
- px_r, py_r = sp.Rational(px).limit_denominator(1000), sp.Rational(py).limit_denominator(1000)
- dist = sp.sqrt((px_r - cx_r)**2 + (py_r - cy_r)**2)
- dist_val = sp.simplify(dist) # SymPy exact value (e.g. 5)
- radius_sym = sp.Rational(context.radius).limit_denominator(1000)
-
- # 🚨 [SOP FIX] Guard against NoneType before float cast
- for val in [dist_val, radius_sym]:
- if val is None or val == "null" or val == "":
- raise ValueError("Missing required mathematical argument from LLM. Cannot cast None to float.")
-
- # Comparison via Python float (avoids SymPy Float vs int ambiguity)
- dist_float = float(dist_val)
- radius_float = float(radius_sym)
- if abs(dist_float - radius_float) < 1e-9:
- on_circle = "על המעגל"
- elif dist_float < radius_float:
- on_circle = "בתוך המעגל"
- else:
- on_circle = "מחוץ למעגל"
- result = [f"d = {dist_val}", f"r = {context.radius}", on_circle]
- logger.info(f"[SOLVER] ✅ CALCULATE_DISTANCE point={context.point_a} center={context.center} → {result}")
- return sign_step_geometry(result, problem_id, step_id)
-
- # ── Legacy Algebraic Action Dispatch ──────────────────────────────────────
- ACTION_MAP = {
- "SOLVE_EQUATION": lambda e: sp.solve(e, solve_for) if solve_for else sp.solve(e),
- "SIMPLIFY": lambda e: [sp.simplify(e)],
- "FACTOR": lambda e: [sp.factor(e)],
- "EXPAND": lambda e: [sp.expand(e)],
- "FIND_DERIVATIVE": lambda e: [sp.diff(e, solve_for or (free_syms[0] if free_syms else sp.Symbol('x')))],
- "FIND_INTEGRAL": lambda e: [sp.integrate(e, solve_for or (free_syms[0] if free_syms else sp.Symbol('x')))],
- "SUBSTITUTE": lambda e: [e],
- }
-
- fn = ACTION_MAP.get(action)
- if not fn:
- raise ValueError(f"[SOLVER] Unknown action: '{action}'. Check ComputeAction enum.")
-
- try:
- result = fn(expr)
- if not isinstance(result, list):
- result = [result]
- logger.info(f"[SOLVER] ✅ {action} on '{expression}' → {result}")
- except Exception as e:
- raise RuntimeError(f"[SOLVER] SymPy failed on action '{action}': {e}")
-
- return sign_step(result, problem_id, step_id)
-
-
-
-
-class MathState(BaseModel):
- """ייצוג מתמטי אחיד של מצב הבעיה"""
- equations: List[Any] = Field(default_factory=list) # רשימת משוואות SymPy
- solved_vars: Dict[Any, Any] = Field(default_factory=dict) # משתנים שנפתרו
- original_text: str = ""
-
- class Config:
- arbitrary_types_allowed = True
-
- def is_solved(self) -> bool:
- # במערכת משוואות MVP: אם יש משתנים פתורים ואין עוד משוואות בלתי פתורות רלוונטיות
- if not self.equations:
- return len(self.solved_vars) > 0
- all_syms = set()
- for eq in self.equations:
- all_syms.update(eq.free_symbols)
- # נפתר אם כל המשתנים מקבלים ערך
- return len(all_syms) > 0 and all(sym in self.solved_vars for sym in all_syms)
-
-# ==================== V5.8.0 RULE ENGINE MVP ====================
-
-class MathRule:
- name: str = "BaseRule"
-
- def is_applicable(self, state: MathState) -> bool:
- return False
-
- def apply(self, state: MathState) -> Tuple[MathState, Any]:
- raise NotImplementedError()
-
-class RuleSolveLinearSystem(MathRule):
- name = "פתרון מערכת משוואות"
-
- def __init__(self):
- self.x, self.y = symbols('x y')
-
- def is_applicable(self, state: MathState) -> bool:
- # נזהה מערכת של 2 משוואות עם 2 נעלמים חופשיים
- if len(state.equations) >= 2:
- syms = set()
- for eq in state.equations:
- syms.update(eq.free_symbols)
- if len(syms) == 2:
- return True
- return False
-
- def apply(self, state: MathState) -> Tuple[MathState, Any]:
- new_state = copy.copy(state)
- # ניסיון פתרון סימבולי למערכת המשוואות יחד
- eq1 = state.equations[0]
- eq2 = state.equations[1]
-
- # V5.8.1: Detailed Steps Mode (Step Granularity)
- # Check if both equations are of the form "sym = expression" and have the same LHS
- if eq1.lhs == eq2.lhs and isinstance(eq1.lhs, Symbol):
- lhs_sym = eq1.lhs
- # Get the other symbol playing the role of x
- rhs_syms = list((eq1.rhs.free_symbols | eq2.rhs.free_symbols) - {lhs_sym})
- if len(rhs_syms) == 1:
- rhs_sym = rhs_syms[0]
- steps = []
-
- # 1. Equate
- eq_step = Eq(eq1.rhs, eq2.rhs)
- steps.append({"logic": "נשווה בין שתי המשוואות", "math": latex(eq_step), "rule_id": "solve_linear_system"})
-
- # 2. Collect terms
- diff_expr = eq_step.lhs - eq_step.rhs
- c = diff_expr.coeff(rhs_sym)
- d = diff_expr.subs(rhs_sym, 0)
- collected_eq = Eq(c * rhs_sym, -d)
- steps.append({"logic": "נעביר אגפים ונכנס איברים דומים", "math": latex(collected_eq), "rule_id": "solve_linear_system"})
-
- # 3. Isolate variable
- x_val = -d / c
- isolated_eq = Eq(rhs_sym, x_val)
- steps.append({"logic": f"נחלק במקדם של {latex(rhs_sym)}", "math": latex(isolated_eq), "rule_id": "solve_linear_system"})
-
- # 4. Substitute back
- y_val = eq1.rhs.subs(rhs_sym, x_val)
- # string replacement for substitution display to avoid auto-evaluation collapsing it
- subs_str = latex(eq1.rhs).replace(latex(rhs_sym), f"({latex(x_val)})")
- steps.append({"logic": f"נציב את {latex(rhs_sym)} באחת המשוואות", "math": f"{latex(lhs_sym)} = {subs_str} = {latex(y_val)}", "rule_id": "solve_linear_system"})
-
- new_state.solved_vars[rhs_sym] = x_val
- new_state.solved_vars[lhs_sym] = y_val
- new_state.equations = []
- return new_state, steps
-
- # V5.8.3 The Ultimate Guard: No step breakdown -> No solve
- return state, "לא ניתן לפרק לצעדים מדורגים."
-
-class RuleIsolateVariable(MathRule):
- name = "בידוד משתנה"
- def is_applicable(self, state: MathState) -> bool:
- # האם יש משוואה אחת שניתן לבודד ממנה משתנה שעדיין לא בודד?
- if len(state.equations) == 1:
- return True
- return False
-
- def apply(self, state: MathState) -> Tuple[MathState, Any]:
- new_state = copy.copy(state)
- eq = state.equations[0]
- syms = list(eq.free_symbols)
- if not syms: return state, "אין משתנים לבידוד."
- # ננסה לבודד את המשתנה (למשל x)
- sol = solve(eq, syms[0])
- if sol:
- new_state.solved_vars[syms[0]] = sol[0]
- new_state.equations = []
- steps = [{"logic": "נבודד את המשתנה", "math": f"{latex(syms[0])} = {latex(sol[0])}", "rule_id": "isolate_variable"}]
- return new_state, steps
- return state, "לא ניתן לבודד משתנה"
-
-class RuleSubstitute(MathRule):
- name = "הצבת משתנה שבודד"
- def is_applicable(self, state: MathState) -> bool:
- return len(state.solved_vars) > 0 and len(state.equations) > 0
-
- def apply(self, state: MathState) -> Tuple[MathState, Any]:
- new_state = copy.copy(state)
- # נציב את כל המשתנים הידועים בתוך המשוואות שנותרו
- new_eqs = []
- for eq in state.equations:
- new_eq = eq.subs(state.solved_vars)
- new_eqs.append(new_eq)
- new_state.equations = new_eqs
- # אנחנו לא מוחקים את state.solved_vars כי נרצה לזכור אותם להמשך
- str_vars = ", ".join([f"{latex(k)} = {latex(v)}" for k,v in state.solved_vars.items()])
- steps = [{"logic": "נציב את הערכים הידועים במשוואות", "math": str_vars, "rule_id": "substitute"}]
- return new_state, steps
-
-
-class StepValidator:
- """
- V6.1 Phase 3: Validation Authority
- Acts as the 'Supreme Court' for LLM proposed ProofGraphs.
- """
-
- ALLOWED_CONSTANTS = {'pi', 'E', 'sqrt(2)'} # Removed 'I' as per QA Gate requirements.
-
- @classmethod
- def sympy_simplify_tolerance_guard(cls, expr_n, expr_n_plus_1) -> bool:
- """
- V6.1 Phase 4: Tolerance Guard
- Numerical evaluation fallback to handle micro-variances that avoid simplification.
- """
- import random
- try:
- diff_expr = expr_n - expr_n_plus_1
- free_syms = diff_expr.free_symbols
-
- # If no free symbols, just evaluate numerically
- if not free_syms:
- val = diff_expr.evalf()
- # 🚨 [SOP FIX] Guard against NoneType before float cast
- if val is None or val == "null" or val == "":
- raise ValueError("Missing required mathematical argument from LLM. Cannot cast None to float.")
- return abs(float(val)) < 1e-9
-
- # Sampling: 10 random points
- for _ in range(10):
- subs = {s: random.uniform(0.1, 10.0) for s in free_syms}
- val = diff_expr.evalf(subs=subs)
- # 🚨 [SOP FIX] Guard against NoneType before float cast
- if val is None or val == "null" or val == "":
- raise ValueError("Missing required mathematical argument from LLM. Cannot cast None to float.")
- if abs(float(val)) > 1e-9: # Precision set to 10^-9 as per QA Gate
- return False
- return True
- except Exception:
- return False
-
- @classmethod
- def validate_transition(cls, step_n: str, step_n_plus_1: str) -> bool:
- """
- Deterministically verifies the algebraic equivalence between two steps.
- """
- try:
- # V9.0.1: Aggressive sanitization before comparison
- # We take the first expression from the sanitizer if multiple are returned
- parts_n = aggressive_sympy_sanitizer(str(step_n))
- parts_n_plus_1 = aggressive_sympy_sanitizer(str(step_n_plus_1))
-
- if not parts_n or not parts_n_plus_1:
- return False
-
- clean_n = parts_n[0].replace('=', '-')
- clean_n_plus_1 = parts_n_plus_1[0].replace('=', '-')
-
- expr_n = sympify(clean_n, evaluate=False)
- expr_n_plus_1 = sympify(clean_n_plus_1, evaluate=False)
-
- # Use simplify to check equivalence
- diff = simplify(expr_n - expr_n_plus_1)
-
- if diff == 0:
- return True
-
- # Fallback for trigonometric identities or complex simplification
- if trigsimp(expr_n) == trigsimp(expr_n_plus_1):
- return True
-
- # V6.1 Phase 4: Tolerance Guard (Secondary Check)
- if cls.sympy_simplify_tolerance_guard(expr_n, expr_n_plus_1):
- logger.info(f"🛡️ [VALIDATOR] Tolerance Guard PASSED for {step_n} -> {step_n_plus_1}")
- return True
-
- # If equivalence fails, it might be an irreversible action (e.g. squaring).
- return False
-
- except Exception as e:
- logger.warning(f"[VALIDATOR] Transition validation error '{step_n}' -> '{step_n_plus_1}': {e}")
- return False
-
- @classmethod
- def check_proofgraph_closure(cls, initial_math: str, final_step: str) -> bool:
- """
- Ensures all variables in the original problem are resolved or accounted for,
- and no hallucinations occurred via injected fake constants/variables.
- """
- try:
- # V9.0.1: Aggressive sanitization
- initial_parts = aggressive_sympy_sanitizer(str(initial_math))
- initial_exprs = [sympify(str(p).replace('=', '-'), evaluate=False) for p in initial_parts]
- initial_vars = set()
- for ex in initial_exprs:
- initial_vars.update(ex.free_symbols)
-
- final_parts = aggressive_sympy_sanitizer(str(final_step))
- final_exprs = [sympify(str(p).replace('=', '-'), evaluate=False) for p in final_parts]
- final_vars = set()
- for ex in final_exprs:
- final_vars.update(ex.free_symbols)
-
- filtered_final_vars = {str(v) for v in final_vars if not v.is_number and str(v) not in cls.ALLOWED_CONSTANTS}
- filtered_initial_vars = {str(v) for v in initial_vars if not v.is_number and str(v) not in cls.ALLOWED_CONSTANTS}
-
- if len(filtered_final_vars - filtered_initial_vars) > 0:
- logger.warning(f"[VALIDATOR] Closure check failed. Leaked vars: {filtered_final_vars - filtered_initial_vars}")
- return False
-
- return True
- except Exception as e:
- logger.warning(f"[VALIDATOR] Closure validation error: {e}")
- return False
-
- @classmethod
- def evaluate_proposal(cls, initial_math: str, draft_steps: list) -> tuple[float, str]:
- """
- Evaluates the entire Draft ProofGraph.
- Returns (Validation Score [0.0 - 1.0], Error Reason)
- """
- if not draft_steps:
- return 0.0, "EMPTY_DRAFT"
-
- valid_transitions = 0
- total_transitions = len(draft_steps)
-
- # We assume step 0 is the initial math or a reformatted version of it.
- # We validate transition from step[i] to step[i+1]
- for i in range(len(draft_steps) - 1):
- math_n = draft_steps[i].get('math', '')
- math_n_plus_1 = draft_steps[i+1].get('math', '')
-
- if cls.validate_transition(math_n, math_n_plus_1):
- valid_transitions += 1
- else:
- logger.warning(f"[VALIDATOR] Rejecting step {i+1} -> {i+2}: {math_n} to {math_n_plus_1}")
- return 0.0, f"מעבר מתמטי שגוי בין: {math_n} לבין {math_n_plus_1}"
-
- score = valid_transitions / total_transitions if total_transitions > 0 else 0.0
-
- # Check Closure
- final_step_math = draft_steps[-1].get('math', '')
- if not cls.check_proofgraph_closure(initial_math, final_step_math):
- return 0.0, "סגירת פתרון לא חוקית (נמצאו משתנים לא מאושרים)"
-
- return score, ""
-
-
- def __init__(self, success=False, function=None, derivative=None, steps=None, operator_used=None):
- self.success = success
- self.function = function
- self.derivative = derivative
- self.steps = steps or [] # חובה עבור ה-ProofGraph
- self.operator_used = operator_used
-
-class SmartSolver:
- def __init__(self):
- print("✅ 🟢 [BIT-LOG: SmartSolver V263.0] - Algebra Engine Active")
-
- def solve(self, context):
- math_input = context.math_input
- category = context.category
- original_text = getattr(context, 'original_text', "").lower()
- grade_num = getattr(context, 'grade_num', 12)
-
- print(f"🔍 [BIT-LOG: SOLVER] Analyzing input: '{math_input}'")
- print(f"🔍 [BIT-LOG: SOLVER] Category: {category}, Intent Text: '{original_text[:50]}...'")
-
- # --- V5.8.0 Rule Engine MVP (Always First) ---
- print("🔢 [BIT-LOG: SOLVER] Triggering Rule Engine MVP")
- rule_engine_res = self._solve_linear_system(math_input)
- if rule_engine_res and rule_engine_res.success:
- return rule_engine_res
-
- # --- ענף 2: חקירת פונקציות (כיתה י' עד י"ב) ---
- # V4.2.12: Anchor regex to start of string to avoid matching 4x + 5y = 120 as 'y = 120'
- is_explicit_func = bool(re.match(r'^(f\(x\)|y|g\(x\))\s*=', math_input, re.IGNORECASE))
- investigation_keywords = ["חקור", "חקירת", "קיצון", "אסימפטוט", "נגזרת", "עליה", "ירידה"]
- has_intent = any(kw in original_text for kw in investigation_keywords)
-
- # הנגזרת תופעל רק אם: זו חקירה + יש פונקציה מפורשת + יש כוונה בטקסט
- should_derive = (category == "INVESTIGATION" and is_explicit_func and has_intent and grade_num > 7)
-
- if should_derive:
- print("📈 [BIT-LOG: SOLVER] Triggering Calculus Engine (Derivatives)")
- return self._solve_calculus(math_input)
-
- # Fallback גנרי
- print(f"🛡️ [BIT-LOG: SOLVER] Generic Algebra Mode. category={category}, intent={has_intent}")
- return SmartResult(success=True, function=math_input, operator_used="ALGEBRA_GENERAL")
-
- def _solve_linear_system(self, raw_input):
- """V5.8.0: מנוע קבלת החלטות מבוסס MVP חוקים"""
- try:
- # שלב 1: Parsing
- eq_parts = re.split(r'[,\n]', raw_input)
- parsed_eqs = []
- for part in eq_parts:
- if '=' in part:
- s_a, s_b = part.split('=')
- parsed_eqs.append(Eq(sympify(self._sanitize(s_a)), sympify(self._sanitize(s_b))))
-
- if not parsed_eqs:
- return SmartResult(success=False)
-
- current_state = MathState(equations=parsed_eqs, original_text=raw_input)
- rules = [RuleSolveLinearSystem(), RuleIsolateVariable(), RuleSubstitute()]
- proof_graph_steps = []
- iteration = 0
- MAX_ITER = 5
-
- logger.info(f"🧮 [RULE-ENGINE] Starting resolution for system: {parsed_eqs}")
-
- # שלב 2: לולאת החוקים הארכיטקטונית (The Engine)
- while not current_state.is_solved() and iteration < MAX_ITER:
- applied_any = False
- for rule in rules:
- if rule.is_applicable(current_state):
- new_state, step_data = rule.apply(current_state)
- if isinstance(step_data, list):
- for s in step_data:
- proof_graph_steps.append({
- "id": len(proof_graph_steps) + 1,
- "logic": s["logic"],
- "math": s["math"]
- })
- else:
- proof_graph_steps.append({
- "id": len(proof_graph_steps) + 1,
- "logic": rule.name,
- "math": step_data
- })
- current_state = new_state
- applied_any = True
- break # Start rules over with new state
-
- if not applied_any:
- logger.warning("🧮 [RULE-ENGINE] No applicable rules found to continue solving.")
- break
-
- iteration += 1
-
- # יצירת מודל SmartResult עם הוכחה מבוססת חוקים
- if current_state.is_solved():
- print(f"✅ [RULE-ENGINE] Resolved system to state: {current_state.solved_vars}")
- # הוספת צעד סיכום אחרון
- res_str = ", ".join([f"{latex(k)}={latex(v)}" for k,v in current_state.solved_vars.items()])
-
- return SmartResult(success=True, function=raw_input, steps=proof_graph_steps, operator_used="RULE_ENGINE_SYSTEM")
- else:
- return SmartResult(success=False)
-
- except Exception as e:
- print(f"❌ [BIT-LOG: SOLVER] Algebra Rule Engine Error: {e}")
- return SmartResult(success=False)
-
- def _solve_calculus(self, math_input):
- try:
- x = symbols('x')
- # חילוץ הביטוי אחרי ה- '='
- expr_part = math_input.split('=')[-1]
- expr_str = self._sanitize(expr_part)
- logger.info(f"🧮 [TRACE] EXPRESSION SENT TO SYMPY: {expr_str}")
- expr = sympify(expr_str)
- f_prime = diff(expr, x)
-
- # חישוב נקודות קיצון
- crit_points = []
- try:
- sols = solve(f_prime, x)
- for s in sols:
- if s is not None and s.is_real:
- y_val = expr.subs(x, s).evalf()
- if y_val is not None:
- crit_points.append(f"({float(s):.2f}, {float(y_val):.2f})")
- except Exception as e:
- logger.warning(f"[SOLVER] Calculus point evaluation failed: {e}")
-
- steps = [{"id": 1, "math": f"f'(x)={latex(f_prime)}", "logic": "חישוב נגזרת"}]
- return SmartResult(
- success=True,
- function=f"f(x)={latex(expr)}",
- derivative=f"f'(x)={latex(f_prime)}",
- steps=steps,
- operator_used="DERIVATIVE"
- )
- except Exception as e:
- print(f"❌ [BIT-LOG: SOLVER] Calculus Engine Error: {e}")
- return SmartResult(success=False)
-
- def _sanitize(self, text):
- """✅ סנכרון עם מנגנון הניקוי המרכזי (V260.0)"""
- text = text.replace(r'\left', '').replace(r'\right', '')
- while r'\frac' in text:
- text = re.sub(r'\\frac\s*\{(.*?)\}\{(.*?)\}', r'(\1)/(\2)', text)
-
- text = re.sub(r'(sin|cos|tan)\s+([a-zA-Z0-9]+)', r'\1(\2)', text)
- text = text.replace(r'\sin', 'sin').replace(r'\cos', 'cos').replace(r'\tan', 'tan')
- text = text.replace('^', '**')
-
- # ✅ V260.0: Robust implicit multiplication
- text = re.sub(r'(\d)([a-zA-Z(])', r'\1*\2', text)
- text = re.sub(r'\)([a-zA-Z0-9(])', r')*\1', text)
- text = re.sub(r'(? str:
- """
- V6.1: Direct raw LLM call for ProposalEngine. Unconstrained output.
- """
- from google.generativeai.types import GenerationConfig
- import asyncio
-
- full_prompt = f"{system_prompt}\n\nUser Request: {user_prompt}"
-
- gen_config = GenerationConfig(
- temperature=0.2, # Slight creativity needed to draft proofs, but still grounded
- top_p=0.8,
- )
-
- logger.info(f"🧠 [PROPOSAL-GATEWAY] Sending PROMPT to LLM.")
- try:
- response = await asyncio.wait_for(
- asyncio.to_thread(self.llm.generate_content, full_prompt, generation_config=gen_config),
- timeout=45.0
- )
- return getattr(response, 'text', '')
- except Exception as e:
- logger.error(f"❌ [PROPOSAL-GATEWAY] LLM Request failed: {e}")
- raise e
diff --git a/strategy_policy_engine.py b/strategy_policy_engine.py
deleted file mode 100644
index 8f99896e72d6290598e76ef975bdbcd1d2493671..0000000000000000000000000000000000000000
--- a/strategy_policy_engine.py
+++ /dev/null
@@ -1,20 +0,0 @@
-# strategy_policy_engine.py
-
-STRATEGY_MAP = {
- "GEOMETRY_ANALYTIC": {
- "allowed": ["algebraic_intersection", "distance_formula", "midpoint_calculation"],
- "forbidden": ["differentiation", "calculus_modeling", "optimization"]
- },
- "SIMPLE_LINEAR_SOLVE": {
- "allowed": ["single_variable_isolation", "basic_arithmetic"],
- "forbidden": ["systems_of_equations", "substitution_method"]
- },
- "FUNCTION_ANALYSIS": {
- "allowed": ["differentiation", "extrema_finding", "intercepts"],
- "forbidden": ["integration"]
- }
-}
-
-def get_allowed_strategies(intent):
- """מחזיר וקטור אסטרטגיות מותרות לפי כוונת המשתמש"""
- return STRATEGY_MAP.get(intent, {"allowed": ["GENERAL"], "forbidden": []})
diff --git a/student_limits.json b/student_limits.json
deleted file mode 100644
index 1310e01bee3dc65ca21fa9bb13cb1e87a4606acc..0000000000000000000000000000000000000000
--- a/student_limits.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "default_limit": 30,
- "blocked_users": [],
- "students": {
- "test_student": 100,
- "vlTppBQtqfRScaxWpBMbxWfVCrF2": -1,
- "test_user_123": 42
- }
-}
\ No newline at end of file
diff --git a/system_prompt.txt b/system_prompt.txt
deleted file mode 100644
index bf867ce72c8fb0a4e494e2a0e73c362063b34e7d..0000000000000000000000000000000000000000
--- a/system_prompt.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-You are a helpful math tutor for Hebrew speakers.
-
-STRICT OUTPUT RULES:
-1. **Language:** Write explanations in natural, clear Hebrew.
-2. **Math Formatting:** Any mathematical expression, equation, number, variable (x, y), or formula MUST be wrapped in double dollar signs ($$).
- * Correct: "השיפוע הוא $$m=3$$."
- * Incorrect: "השיפוע הוא m=3."
-3. **No Hebrew inside Math:** Never put Hebrew text inside the $$ delimiters.
-4. **Inline Only:** Do not use block formatting like \[ ... \]. Use $$...$$ for everything math-related.
-5. **Structure:** Explain the solution step-by-step.
\ No newline at end of file
diff --git a/templates/payment.html b/templates/payment.html
deleted file mode 100644
index 4db3c0cdda4577a9016cd6fcbb7c795ef1de145e..0000000000000000000000000000000000000000
--- a/templates/payment.html
+++ /dev/null
@@ -1,89 +0,0 @@
-
-
-
-
-
- המורה למתמטיקה | שדרוג לפרימיום
-
-
-
-
-
-
-
-
- 👨🏫
-
-
-
-
- בואו נפתח לו את כל המחסומים בדרך להצלחה במתמטיקה.
- עם פרימיום הוא יקבל פתרונות ללא הגבלה ודוחות התקדמות שבועיים ישירות למייל.
-
-
-
-
-
-
מזל טוב! 🎉
-
המנוי שודרג בהצלחה. ספרו על כך בחזרה באפליקציה!
-
-
-
-
-
-
-
diff --git a/templates/report_template.html b/templates/report_template.html
deleted file mode 100644
index 9177b777272e13e84b11b647c7f763c97f72ee67..0000000000000000000000000000000000000000
--- a/templates/report_template.html
+++ /dev/null
@@ -1,251 +0,0 @@
-
-
-
-
- דוח התקדמות שבועי - המורה למתמטיקה
-
-
-
-
-
-
-
-
-
סה"כ תרגילים שנפתרו
-
{{ total_exercises }}
-
-
-
ממוצע שליטה כללי
-
{{ overall_mastery }}%
-
-
-
-
-
-
-
מפת מיומנויות
-

-
-
-
-
פירוט נושאים
- {% for skill in skills %}
-
-
-
-
- תתי-נושאים שטופלו: {{ skill.sub_skills | join(", ") }}
-
-
- {% endfor %}
-
-
-
- {% if parent_notes and parent_notes|length > 0 %}
-
-
נקודות לשימור ולחיזוק 💡
- {% for note in parent_notes %}
-
{{ note }}
- {% endfor %}
-
- {% endif %}
-
- {% if focus_for_next_week %}
-
-
מטרה לשבוע הבא 🎯
-
{{ focus_for_next_week }}
-
- {% endif %}
-
-
-
-
-
diff --git a/test_emergency_recovery.py b/test_emergency_recovery.py
deleted file mode 100644
index 026fd6bed7b50d3d5e70b81ff1ac37af8300fff8..0000000000000000000000000000000000000000
--- a/test_emergency_recovery.py
+++ /dev/null
@@ -1,64 +0,0 @@
-import asyncio
-import os
-import sys
-import json
-import base64
-
-# Add the server directory to path
-sys.path.append(os.getcwd())
-
-async def run_emergency_test():
- from orchestrator import orchestrator, BuddyEvent, BuddyState
-
- print("🎬 Starting Emergency Recovery Test...")
-
- # Mock image data (just some bytes)
- mock_image = b"fake-image-data-V316"
-
- # Complex problem text reported by user
- problem_text = "תרגיל ד: חקור את הפונקציה f(x) = ln|x^2 - 1| / (ln x)^2"
-
- print(f"📝 Testing with Problem: {problem_text}")
- print("🔍 Step 1: Vision Pipeline & Data Anchor...")
-
- events_found = []
- try:
- # We use a real orchestrator but expect it to handle our mock data or at least not crash
- async for event in orchestrator.solve_problem(
- problem_text=problem_text,
- image_data_list=[mock_image],
- grade="5 יח\"ל",
- student_name="אלון",
- student_gender="M",
- mode="standard"
- ):
- events_found.append(event.state)
- print(f"📡 Event: {event.state}")
-
- if event.state == BuddyState.COMPLETE:
- print("✅ TEST PASSED: Reached COMPLETE state without server crash.")
- break
-
- if event.state == BuddyState.ERROR:
- print(f"⚠️ Event reported error (logic error likely), but system stayed ALIVE. Payload: {event.payload}")
- break
-
- except Exception as e:
- print(f"❌ TEST FAILED: System CRASHED with error: {e}")
- import traceback
- traceback.print_exc()
- return False
-
- return True
-
-if __name__ == "__main__":
- # Ensure environment variables are semi-valid for local run
- os.environ["ENV"] = "development"
- os.environ["SPACE_ID"] = "dotandru/buddymath-dev"
-
- success = asyncio.run(run_emergency_test())
- if success:
- print("\n🏆 EMERGENCY RECOVERY VALIDATED.")
- else:
- print("\n💀 VALIDATION FAILED.")
- sys.exit(1)
diff --git a/test_llm.py b/test_llm.py
deleted file mode 100644
index c1509364eeee5d2855cf94bb9268e58eda5fe07e..0000000000000000000000000000000000000000
--- a/test_llm.py
+++ /dev/null
@@ -1,30 +0,0 @@
-import asyncio
-import logging
-import json
-import traceback
-from orchestrator import orchestrator
-
-logging.basicConfig(level=logging.INFO, format="%(message)s")
-
-async def test_llm():
- try:
- with open(r"C:\Projects\BuddyMath\simple.jpg", "rb") as f:
- image_bytes = f.read()
-
- # Bypassing FastAPI layer, calling orchestrator directly
- print("Starting orchestrator.solve_problem...")
- res = await orchestrator.solve_problem(
- problem_text="",
- grade="10",
- student_name="diagnostic",
- image_data=image_bytes
- )
- print("--- FINAL RESPONSE ---")
- print(json.dumps(res, indent=2, ensure_ascii=False))
-
- except Exception as e:
- print(f"Test Failed: {e}")
- traceback.print_exc()
-
-if __name__ == "__main__":
- asyncio.run(test_llm())
diff --git a/test_rule_engine.py b/test_rule_engine.py
deleted file mode 100644
index e297cfd98ee3154acaa7868dd3c7a91d1090f277..0000000000000000000000000000000000000000
--- a/test_rule_engine.py
+++ /dev/null
@@ -1,29 +0,0 @@
-import asyncio
-import logging
-import json
-import traceback
-from orchestrator import orchestrator
-from domain.processing_strategy import ProcessingStrategy
-
-logging.basicConfig(level=logging.INFO, format="%(message)s")
-
-async def test_rule_engine():
- try:
- problem_text = "נתון מעגל שמשוואות שניים מקטריו הן y = 4/3*x + 1 ו- y = -4/3*x + 9. מצא את מרכז המעגל."
- # We want to force the STRICT_SYMBOLIC or just pass it to solve_problem
- print("Starting orchestrator.solve_problem with Rule Engine test...")
- res = await orchestrator.solve_problem(
- problem_text=problem_text,
- grade="10",
- student_name="diagnostic",
- image_data=None # No image, just text
- )
- print("--- FINAL RESPONSE ---")
- print(json.dumps(res, indent=2, ensure_ascii=False))
-
- except Exception as e:
- print(f"Test Failed: {e}")
- traceback.print_exc()
-
-if __name__ == "__main__":
- asyncio.run(test_rule_engine())
diff --git a/test_tts.py b/test_tts.py
deleted file mode 100644
index 46f3c9319546e8310f35b17aeecf5aac8a2ea3a3..0000000000000000000000000000000000000000
--- a/test_tts.py
+++ /dev/null
@@ -1,70 +0,0 @@
-import os
-import urllib.request
-import torchaudio
-import re
-import torch
-import warnings
-
-warnings.filterwarnings("ignore")
-
-# =========================================================================
-# פתרון גנרי (Monkey Patch):
-# עוקפים באגים בספריות צד-שלישי ששכחו לתמוך במעבד (CPU).
-# אנחנו מכריחים את PyTorch לטעון את כל המשקולות ישירות למעבד.
-# =========================================================================
-original_load = torch.load
-
-def safe_cpu_load(*args, **kwargs):
- kwargs['map_location'] = 'cpu'
- return original_load(*args, **kwargs)
-
-torch.load = safe_cpu_load
-# =========================================================================
-
-from chatterbox.mtl_tts import ChatterboxMultilingualTTS
-from phonikud_onnx import Phonikud
-from phonikud import lexicon
-
-def main():
- print("🚀 מתחילים! מכינים את סביבת העבודה...")
-
- # הורדת מודל הניקוד (ידולג כי כבר ירד)
- nikud_model_path = "phonikud-1.0.int8.onnx"
- if not os.path.exists(nikud_model_path):
- urllib.request.urlretrieve("https://huggingface.co/thewh1teagle/phonikud-onnx/resolve/main/phonikud-1.0.int8.onnx", nikud_model_path)
-
- print("⏳ טוען את מנועי ה-AI לזיכרון...")
-
- device = "cpu"
- phonikud_model = Phonikud(nikud_model_path)
-
- # === תוקן: חסרו מירכאות בתחילת המחרוזת ===
- text = "בוקר טוב לקהילה הקטנה שלי!! מה שלומכם הבוקר איך אני נשמעת לכם ? מוזר? נעים? עליתי לארץ ללמד מתמטיקה . ספרו לי מה אתם חושבים."
- ref_path = "teacher_reference.wav"
- output_path = "first_test.wav"
-
- # ניקוד אוטומטי
- with_diacritics = phonikud_model.add_diacritics(text)
- with_diacritics = re.sub(fr"[{lexicon.NON_STANDARD_DIAC}]", "", with_diacritics)
- print(f"📝 הטקסט המנוקד שהמנוע יקרא: {with_diacritics}")
-
- # הפעלת מנוע ה-Chatterbox
- print("\n🎙️ מייצר אודיו... (המעבד מחשב, המתן בסבלנות)")
-
- multilingual_model = ChatterboxMultilingualTTS.from_pretrained(device=device)
-
- # === תוקן: שליטה במהירות במנוע Chatterbox ===
- # שימוש ב-cfg_weight כדי להאט את קצב הדיבור. 0.5 זה רגיל, 0.75 יאט אותה לקצב נוח וברור יותר.
- wav = multilingual_model.generate(
- with_diacritics,
- language_id="he",
- audio_prompt_path=ref_path,
- cfg_weight=0.75
- )
-
- # שמירה לדיסק
- torchaudio.save(output_path, wav, multilingual_model.sr)
- print(f"\n✅✅✅ הצלחה אדירה! הקובץ {output_path} נוצר בהצלחה!")
-
-if __name__ == "__main__":
- main()
\ No newline at end of file
diff --git a/tests/archive_scripts/final_stress_test.py b/tests/archive_scripts/final_stress_test.py
deleted file mode 100644
index 2801b9be053b52d7718b8e9b5fcfa98be68462ed..0000000000000000000000000000000000000000
--- a/tests/archive_scripts/final_stress_test.py
+++ /dev/null
@@ -1,84 +0,0 @@
-import cv2
-import numpy as np
-import os
-from PIL import Image
-from google import genai
-from google.genai import types
-
-# 1. הגדרות (שים את המפתח החדש שלך!)
-client = genai.Client(api_key="AIzaSyA0odvjKCTS-vZsEjMOlsVsJ4iGaYj5jf0")
-
-def process_and_ocr(image_path):
- print(f"\n--- Processing: {image_path} ---")
-
- # 2. OpenCV - זיהוי שורות וחיתוך
- img = cv2.imread(image_path)
- gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
- blur = cv2.GaussianBlur(gray, (7,7), 0)
- thresh = cv2.adaptiveThreshold(blur, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2)
-
- # מחברים שורות (קרנל רחב)
- kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (50, 5))
- dilate = cv2.dilate(thresh, kernel, iterations=1)
- contours, _ = cv2.findContours(dilate, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
-
- # מיון מלמעלה למטה
- contours = sorted(contours, key=lambda c: cv2.boundingRect(c)[1])
-
- crops = []
- max_w = 0
- for c in contours:
- x, y, w, h = cv2.boundingRect(c)
- if w > 60 and h > 20:
- crop = img[y:y+h, x:x+w]
- crops.append(crop)
- if w > max_w: max_w = w
-
- # 3. Stitching - הדבקת החיתוכים לסטריפ אחד (מנקה שטחים לבנים)
- # מוסיפים פדינג שיהיה רוחב אחיד להדבקה
- final_crops = []
- for c in crops:
- h, w, _ = c.shape
- padded = cv2.copyMakeBorder(c, 5, 5, 0, max_w - w, cv2.BORDER_CONSTANT, value=[255, 255, 255])
- final_crops.append(padded)
-
- if not final_crops:
- print("Error: No text blocks found!")
- return
-
- # הדבקה אנכית
- stitched_strip = cv2.vconcat(final_crops)
- strip_name = f"strip_{image_path}"
- cv2.imwrite(strip_name, stitched_strip)
- print(f"Stitched strip saved as {strip_name}")
-
- # 4. שליחה ל-Gemini Flash
- pil_img = Image.open(strip_name)
-
- prompt = """
- התמונה המצורפת היא "סטריפ" המורכב מחיתוכים של שאלת בגרות במתמטיקה.
- המשימה שלך: חלץ את כל הטקסט והמשוואות בדיוק מושלם של 100%.
- חוקים:
- 1. קרא את העברית והמתמטיקה ברצף מלמעלה למטה.
- 2. שים לב לפרטים הקטנים ביותר: שברים, חזקות, e, ln, וסימני מינוס.
- 3. החזר LaTeX תקני לכל המתמטיקה ועברית נקייה.
- 4. החזר רק את הטקסט המחולץ.
- """
-
- response = client.models.generate_content(
- model='gemini-2.0-flash',
- contents=[pil_img, prompt],
- config=types.GenerateContentConfig(temperature=0.0, max_output_tokens=4096)
- )
-
- print("\n--- OCR RESULT ---")
- print(response.text)
- print("------------------")
-
-# הרצת הניסוי על 3 הקבצים (שנה את השמות אם צריך)
-test_files = ["hardest_1.jpg", "ocr_only.jpeg", "simple.jpg"]
-for file in test_files:
- if os.path.exists(file):
- process_and_ocr(file)
- else:
- print(f"File {file} not found, skipping...")
\ No newline at end of file
diff --git a/tests/archive_scripts/run_100_stress.py b/tests/archive_scripts/run_100_stress.py
deleted file mode 100644
index b884f76786c30b853cefbd57869bcd9c3b0d5a87..0000000000000000000000000000000000000000
--- a/tests/archive_scripts/run_100_stress.py
+++ /dev/null
@@ -1,90 +0,0 @@
-import asyncio
-import cv2
-import numpy as np
-import time
-from fastapi.testclient import TestClient
-from main import app
-
-client = TestClient(app)
-
-def create_mock_image(text: str) -> bytes:
- img = np.zeros((200, 600, 3), dtype=np.uint8)
- cv2.putText(img, text, (10, 100), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2)
- success, encoded_image = cv2.imencode('.jpg', img)
- if not success:
- raise ValueError("Failed to encode image")
- return encoded_image.tobytes()
-
-smadar_img_bytes = create_mock_image("4x + 5y = 120, y = x + 6")
-circle_img_bytes = create_mock_image("(x-3)**2 + (y-4)**2 = 25")
-
-def run_stress_test():
- print("🚀 Starting 100-Request OpenCV Stress Test Matrix...")
- print("Expected Triggers: Quota Limits (429), Logic Errors, Valid Passes\n")
-
- stats = {
- "success": 0,
- "logic_error_handled": 0,
- "quota_exceeded": 0,
- "uncaught_exceptions": 0
- }
-
- start_time = time.time()
-
- for i in range(1, 101):
- print(f"--- Request {i}/100 ---")
-
- is_smadar = (i % 2 != 0)
- img_bytes = smadar_img_bytes if is_smadar else circle_img_bytes
- grade = "ז'" if is_smadar else "י'"
-
- files = {'file': ('test.jpg', img_bytes, 'image/jpeg')}
- data = {
- 'user': f'stress_user_{i % 5}', # Distribute across 5 mock users to eventual hit quotas
- 'grade': grade,
- 'student_gender': 'M',
- 'mode': 'solve'
- }
-
- try:
- resp = client.post("/solve_stream", data=data, files=files)
-
- if resp.status_code == 429:
- print(" 🛡️ Quota Exceeded handled correctly (429)")
- stats["quota_exceeded"] += 1
- elif resp.status_code == 200:
- res_json = resp.json()
- if res_json.get("logic_error"):
- print(" 🛡️ Logic Error Handled (Firewall/Validator tripped safely)")
- stats["logic_error_handled"] += 1
- else:
- print(" ✅ Valid Response Generated")
- stats["success"] += 1
- elif resp.status_code == 500:
- print(f" ❌ CRITICAL: 500 Internal Server Error returned: {resp.text}")
- stats["uncaught_exceptions"] += 1
- else:
- print(f" ⚠️ Unexpected Status Code: {resp.status_code}")
- stats["uncaught_exceptions"] += 1
-
- except Exception as e:
- print(f" ❌ Exception Burst: {e}")
- stats["uncaught_exceptions"] += 1
-
- print("\n" + "="*40)
- print("📊 FINAL STRESS TEST RESULTS 📊")
- print(f"Total Requests: 100")
- print(f"Successful Validations: {stats['success']}")
- print(f"Safely Handled Logic Errors: {stats['logic_error_handled']}")
- print(f"Safely Handled Quotas (429): {stats['quota_exceeded']}")
- print(f"Unmanaged Exceptions/500s: {stats['uncaught_exceptions']}")
- print("="*40)
- print(f"Elapsed Time: {time.time() - start_time:.2f}s")
-
- if stats["uncaught_exceptions"] == 0:
- print("✅ STRESS TEST PASSED - 100% STABILITY PROVEN")
- else:
- print("❌ STRESS TEST FAILED - UNCAUGHT EXCEPTIONS DETECTED")
-
-if __name__ == "__main__":
- run_stress_test()
diff --git a/tests/archive_scripts/sanity_check.py b/tests/archive_scripts/sanity_check.py
deleted file mode 100644
index a11856fd2d9b32a59c0ab47df7c8b62961b13658..0000000000000000000000000000000000000000
--- a/tests/archive_scripts/sanity_check.py
+++ /dev/null
@@ -1,32 +0,0 @@
-import asyncio
-from orchestrator import orchestrator
-
-async def run_sanity():
- print("--- 🟢 SANITY CHECK ALGEBRA (SMADAR) ---")
- res_alg = await orchestrator.smart_solve(
- problem_text="סמדר קנתה מחברות חשבון ב-4 שקלים וחלקות ב-5 שקלים. כמה קנתה?",
- data_anchor={"function_equations": ["4x + 5y = 120", "y = x + 6"]},
- grade="ז'",
- category="ALGEBRA",
- image_data=None,
- ambiguity_warning=False
- )
- print("ALGEBRA RESPONSE SUCCESS:")
- print("Final Answer:", res_alg.get("final_answer"))
- print("Logic Error:", res_alg.get("logic_error"))
-
- print("\n--- 🟢 SANITY CHECK GEOMETRY (CIRCLE AREA) ---")
- res_geom = await orchestrator.smart_solve(
- problem_text="חשב את שטח המעגל שמשוואתו (x-3)**2 + (y-4)**2 = 25",
- data_anchor={"function_equations": ["(x-3)**2 + (y-4)**2 = 25"]},
- grade="י'",
- category="GEOMETRY",
- image_data=None,
- ambiguity_warning=False
- )
- print("GEOMETRY RESPONSE SUCCESS:")
- print("Final Answer:", res_geom.get("final_answer"))
- print("Logic Error:", res_geom.get("logic_error"))
-
-if __name__ == "__main__":
- asyncio.run(run_sanity())
diff --git a/tests/archive_scripts/test_atomic_v5.py b/tests/archive_scripts/test_atomic_v5.py
deleted file mode 100644
index beefd98ac2b7832b958d7a1f34ca40a08b3e0e6b..0000000000000000000000000000000000000000
--- a/tests/archive_scripts/test_atomic_v5.py
+++ /dev/null
@@ -1,87 +0,0 @@
-import cv2
-import numpy as np
-from PIL import Image
-import os
-import io
-
-def run_atomic_test(image_path):
- # --- שלב 1: זיהוי ריבועים (הקוד המנצח שלך) ---
- if not os.path.exists(image_path):
- print(f"❌ שגיאה: התמונה לא נמצאה בנתיב: {image_path}")
- return
-
- image = cv2.imread(image_path)
- img_h, img_w = image.shape[:2]
-
- gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
- thresh = cv2.adaptiveThreshold(cv2.GaussianBlur(gray, (7,7), 0), 255,
- cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2)
-
- # הקרנל הרחב שחיבר לך את השורות ב-debug_boxes
- kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (40, 10))
- dilate = cv2.dilate(thresh, kernel, iterations=1)
- contours, _ = cv2.findContours(dilate, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
-
- blocks = []
- for c in contours:
- x, y, w, h = cv2.boundingRect(c)
- if w > 50 and h > 20:
- blocks.append((x, y, w, h))
-
- # מיון הבלוקים מלמעלה למטה
- blocks.sort(key=lambda b: b[1])
-
- # --- שלב 2: חיתוך אטומי (V302.5) ---
- pil_img = Image.open(image_path).convert("RGB")
- pages = []
-
- # חוק 1: Header Lock (תופס את ה-f(x) תמיד)
- header_limit = 180
- pages.append(pil_img.crop((0, 0, img_w, header_limit)))
-
- # ניהול תור
- remaining = [b for b in blocks if (b[1] + b[3]) > header_limit]
-
- while remaining:
- curr = remaining.pop(0)
- x, y, w, h = curr
-
- # חוק 2: אטומיות הגרף (אם גבוה, מקבל דף משלו)
- if h > 120:
- y_s = max(0, y - 30)
- # חוק הגנת הזנב: אם זה האחרון, חתוך עד סוף הקובץ
- y_e = img_h if not remaining else min(img_h, y + h + 30)
- pages.append(pil_img.crop((0, y_s, img_w, y_e)))
- remaining = [b for b in remaining if b[1] > (y_e - 10)]
- continue
-
- # חוק 3: צביר טקסט (קיבוץ סעיפים)
- c_min, c_max = y, y + h
- to_consume = []
- for nb in remaining:
- if nb[3] > 120: break
- if (max(c_max, nb[1]+nb[3]) - c_min) <= 750:
- c_max = max(c_max, nb[1]+nb[3])
- to_consume.append(nb)
- else: break
-
- for r in to_consume: remaining.remove(r)
-
- y_t = max(0, c_min - 25)
- y_b = img_h if not remaining else min(img_h, c_max + 25)
- pages.append(pil_img.crop((0, y_t, img_w, y_b)))
-
- # --- שלב 3: שמירת התוצאות לתיקייה ---
- output_dir = "test_results"
- if not os.path.exists(output_dir): os.makedirs(output_dir)
-
- for i, p in enumerate(pages):
- p.save(f"{output_dir}/slice_{i+1}.jpg")
- print(f"✅ נשמר Slice {i+1} בגובה {p.size[1]}px")
-
- print(f"\n🚀 הבדיקה הסתיימה! בדוק את התמונות בתיקיית: {os.path.abspath(output_dir)}")
-
-if __name__ == "__main__":
- # וודא שהנתיב לתמונה נכון
- target_image = r"C:\Users\dotan\OneDrive\תמונות\בגרות חורף 26 .jpg"
- run_atomic_test(target_image)
\ No newline at end of file
diff --git a/tests/archive_scripts/test_final_assembly.py b/tests/archive_scripts/test_final_assembly.py
deleted file mode 100644
index c4cf1094eaf4400e1bdc62b1b4b8689e1ccc87bb..0000000000000000000000000000000000000000
--- a/tests/archive_scripts/test_final_assembly.py
+++ /dev/null
@@ -1,69 +0,0 @@
-import cv2
-import numpy as np
-from PIL import Image
-import os
-
-def run_final_assembly_test(image_path):
- # --- שלב 1: הזיהוי המנצח שלך (הריבועים הירוקים) ---
- image = cv2.imread(image_path)
- img_h, img_w = image.shape[:2]
-
- gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
- thresh = cv2.adaptiveThreshold(cv2.GaussianBlur(gray, (7,7), 0), 255,
- cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2)
- kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (40, 10))
- dilate = cv2.dilate(thresh, kernel, iterations=1)
- contours, _ = cv2.findContours(dilate, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
-
- blocks = sorted([cv2.boundingRect(c) for c in contours if cv2.boundingRect(c)[2] > 50], key=lambda b: b[1])
-
- # --- שלב 2: בניית ה-Payload (חיבור הריבועים לסלייסים) ---
- pil_img = Image.open(image_path).convert("RGB")
- slices = []
-
- # Header Lock
- slices.append(pil_img.crop((0, 0, img_w, 180)))
-
- remaining = [b for b in blocks if (b[1] + b[3]) > 180]
- while remaining:
- curr = remaining.pop(0)
- x, y, w, h = curr
- if h > 120: # גרף
- y_s, y_e = max(0, y-30), (img_h if not remaining else min(img_h, y+h+30))
- slices.append(pil_img.crop((0, y_s, img_w, y_e)))
- remaining = [b for b in remaining if b[1] > (y_e - 10)]
- continue
-
- c_min, c_max = y, y + h
- to_consume = []
- for nb in remaining:
- if nb[3] > 120 or (max(c_max, nb[1]+nb[3]) - c_min) > 750: break
- c_max = max(c_max, nb[1]+nb[3])
- to_consume.append(nb)
- for r in to_consume: remaining.remove(r)
-
- y_t, y_b = max(0, c_min-25), (img_h if not remaining else min(img_h, c_max+25))
- slices.append(pil_img.crop((0, y_t, img_w, y_b)))
-
- # --- שלב 3: ויזואליזציה של החיבור (ה"ניבוי") ---
- # אנחנו מחברים את כל הסלייסים לתמונה אחת ארוכה עם קו מפריד אדום
- assembly_parts = []
- separator = np.zeros((10, img_w, 3), dtype=np.uint8)
- separator[:] = [0, 0, 255] # קו אדום בולט
-
- for i, s in enumerate(slices):
- assembly_parts.append(np.array(s))
- if i < len(slices) - 1:
- assembly_parts.append(separator)
-
- final_assembly = np.vstack(assembly_parts)
- cv2.imwrite("final_assembly_debug.jpg", cv2.cvtColor(final_assembly, cv2.COLOR_RGB2BGR))
-
- print(f"✅ הצלחנו לחבר {len(slices)} חלקים.")
- print(f"📂 התוצאה הסופית מחכה לך ב: final_assembly_debug.jpg")
- print("\n--- מבנה ה-Payload שיישלח ל-LLM ---")
- print(f"Payload = [PROMPT, " + ", ".join([f"PAGE_{i+1}" for i in range(len(slices))]) + "]")
-
-if __name__ == "__main__":
- target = r"C:\Projects\BuddyMath\buddy_math_server\hardest_1.jpg"
- run_final_assembly_test(target)
\ No newline at end of file
diff --git a/tests/archive_scripts/test_v4_2_16.py b/tests/archive_scripts/test_v4_2_16.py
deleted file mode 100644
index 9cef9d4a4d881d8e3a556d0743d491bbbaad9cd8..0000000000000000000000000000000000000000
--- a/tests/archive_scripts/test_v4_2_16.py
+++ /dev/null
@@ -1,79 +0,0 @@
-# test_v4_2_16.py - REGRESSION TEST FOR ALGEBRA & INTENT GATING
-import asyncio
-from orchestrator import orchestrator, PipelineContext
-
-async def run_tests():
- print("🚀 starting BuddyMath V4.2.16 Regression Test Matrix...\n")
-
- test_cases = [
- {
- "id": "CASE_1_SMADAR_ALGEBRA",
- "name": "סמדר והמחברות (כיתה ז')",
- "grade": "ז'",
- "category": "ALGEBRA",
- "math_input": "4x + 5y = 120, y = x + 6",
- "text": "סמדר קנתה מחברות חשבון ב-4 שקלים וחלקות ב-5 שקלים. כמה קנתה?",
- "expected_operator": "LINEAR_SYSTEM"
- },
- {
- "id": "CASE_2_Y12_ALGEBRA_NO_INTENT",
- "name": "יב' אלגברה - ללא כוונת חקירה",
- "grade": "יב'",
- "category": "INVESTIGATION", # קטגוריה מוטעית בכוונה
- "math_input": "y = 4x + 3",
- "text": "מצא את נקודת החיתוך של הישר עם הצירים.",
- "expected_operator": "ALGEBRA_GENERAL" # אמור להיחסם כי אין מילת 'חקור'
- },
- {
- "id": "CASE_3_Y12_INVESTIGATION_FULL",
- "name": "יב' חקירה - עם כוונת חקירה",
- "grade": "יב'",
- "category": "INVESTIGATION",
- "math_input": "y = x^2 - 4",
- "text": "חקור את הפונקציה ומצא נקודות קיצון.",
- "expected_operator": "DERIVATIVE" # אמור לעבור כי יש 'חקור' ו'קיצון'
- }
- ]
-
- for tc in test_cases:
- print(f"--- Testing: {tc['name']} ---")
-
- # 1. יצירת קונטקסט בדיקה
- context = PipelineContext(
- grade=tc["grade"],
- grade_num=12 if "יב" in tc["grade"] else 7,
- topic=tc["category"],
- math_input=tc["math_input"],
- confidence=1.0,
- original_text=tc["text"],
- category=tc["category"]
- )
-
- # 2. הרצת הסולבר ישירות (בדיקת מנוע המתמטיקה)
- result = orchestrator.smart_solver.solve(context)
- print(f" [SOLVER] Operator Used: {result.operator_used}")
- print(f" [SOLVER] Success: {result.success}")
-
- # 3. בדיקת ה-Validator (בדיקת חסימת זליגות)
- # נדמה פלט של LLM שמנסה להזליג נגזרת באלגברה
- mock_resp = {
- "sections": [{
- "steps": [{"explanation_text": "נחשב את הנגזרת של הפונקציה כדי למצוא פתרון."}]
- }],
- "logic_error": False
- }
-
- from orchestrator import validate_and_sanitize_response
- validated = validate_and_sanitize_response(mock_resp, category=tc["category"])
-
- if tc["category"] != "INVESTIGATION":
- print(f" [VALIDATOR] Logic Error Detected (Expected): {validated['logic_error']}")
-
- # Verification
- if result.operator_used == tc["expected_operator"]:
- print(f"✅ PASSED: {tc['id']}\n")
- else:
- print(f"❌ FAILED: {tc['id']} - Expected {tc['expected_operator']} but got {result.operator_used}\n")
-
-if __name__ == "__main__":
- asyncio.run(run_tests())
\ No newline at end of file
diff --git a/tests/archive_scripts/verify_curriculum_compliance.py b/tests/archive_scripts/verify_curriculum_compliance.py
deleted file mode 100644
index b9bfd95079cef5114e7338fedc1d1291bc4cfda1..0000000000000000000000000000000000000000
--- a/tests/archive_scripts/verify_curriculum_compliance.py
+++ /dev/null
@@ -1,61 +0,0 @@
-# verify_curriculum_compliance.py
-import asyncio
-import sys
-import os
-
-# Add current directory to path
-sys.path.append(os.getcwd())
-
-from orchestrator import BuddyOrchestrator
-from proof_graph import ProofStep, ProofGraph, validate_pedagogical_legality
-import curriculum_engine
-
-async def test_grade_7_violation():
- print("🧪 Testing Grade 7 Violation (Equation Systems)...")
- orchestrator = BuddyOrchestrator()
-
- # Mock data: Grade 7 student
- grade = "7"
- level = "4"
- problem_text = "פתור את מערכת המשוואות: x+y=10, x-y=2"
-
- # 1. Fetch rules
- rules = curriculum_engine.get_allowed_math_operators(grade, level)
- print(f"📋 Rules for Grade 7: Forbidden={rules['forbidden']}")
-
- # 2. Mock a "bad" ProofGraph that uses SYSTEM_OF_EQUATIONS
- bad_steps = [
- ProofStep(1, "x+y=10, x-y=2", "Original System"),
- ProofStep(2, "x=6, y=4", "Solved System", operator_used="SYSTEM_OF_EQUATIONS")
- ]
- bad_graph = ProofGraph(bad_steps)
-
- # 3. Validate
- is_legal, reason = validate_pedagogical_legality(bad_graph, rules)
-
- if not is_legal:
- print(f"✅ Success: Pedagogy Guard correctly REJECTED Grade 7 system solver. Reason: {reason}")
- else:
- print("❌ Failure: Pedagogy Guard ALLOWED forbidden operator.")
-
-async def test_grade_8_allowed():
- print("\n🧪 Testing Grade 8 Allowed (Equation Systems)...")
- grade = "8"
- rules = curriculum_engine.get_allowed_math_operators(grade)
-
- steps = [
- ProofStep(1, "x+y=10, x-y=2", "Original System"),
- ProofStep(2, "x=6, y=4", "Solved System", operator_used="SYSTEM_OF_EQUATIONS")
- ]
- graph = ProofGraph(steps)
-
- is_legal, reason = validate_pedagogical_legality(graph, rules)
-
- if is_legal:
- print(f"✅ Success: Pedagogy Guard correctly ALLOWED Grade 8 system solver.")
- else:
- print(f"❌ Failure: Pedagogy Guard REJECTED allowed operator. Reason: {reason}")
-
-if __name__ == "__main__":
- asyncio.run(test_grade_7_violation())
- asyncio.run(test_grade_8_allowed())
diff --git a/tests/archive_scripts/verify_pedagogy_logic.py b/tests/archive_scripts/verify_pedagogy_logic.py
deleted file mode 100644
index 3ed3b01cc3aa1338cec59a347d77fd4b04b12d94..0000000000000000000000000000000000000000
--- a/tests/archive_scripts/verify_pedagogy_logic.py
+++ /dev/null
@@ -1,83 +0,0 @@
-# verify_pedagogy_logic.py
-import sys
-import os
-
-# Add current directory to path
-sys.path.append(os.getcwd())
-
-from proof_graph import ProofStep, ProofGraph, validate_pedagogical_legality
-import curriculum_engine
-
-def test_grade_7_violation():
- print("🧪 Testing Grade 7 Violation (Equation Systems)...")
-
- # 1. Fetch rules
- rules = curriculum_engine.get_allowed_math_operators("7", "4")
- print(f"📋 Rules for Grade 7: Forbidden={rules['forbidden']}")
-
- # 2. Mock a "bad" ProofGraph that uses SYSTEM_OF_EQUATIONS
- # This simulates the SmartSolver/LLM returning a solution that uses forbidden tools
- bad_steps = [
- ProofStep(1, "x+y=10, x-y=2", "Original System"),
- ProofStep(2, "x=6, y=4", "Solved System", operator_used="SYSTEM_OF_EQUATIONS")
- ]
- bad_graph = ProofGraph(bad_steps)
-
- # 3. Validate
- is_legal, reason = validate_pedagogical_legality(bad_graph, rules)
-
- if not is_legal:
- print(f"✅ Success: Pedagogy Guard correctly REJECTED Grade 7 system solver. Reason: {reason}")
- else:
- print("❌ Failure: Pedagogy Guard ALLOWED forbidden operator.")
-
-def test_grade_8_allowed():
- print("\n🧪 Testing Grade 8 Allowed (Equation Systems)...")
-
- # 1. Fetch rules
- rules = curriculum_engine.get_allowed_math_operators("8", "4")
- print(f"📋 Rules for Grade 8: Forbidden={rules['forbidden']}")
-
- # 2. Mock a "good" ProofGraph for Grade 8
- steps = [
- ProofStep(1, "x+y=10, x-y=2", "Original System"),
- ProofStep(2, "x=6, y=4", "Solved System", operator_used="SYSTEM_OF_EQUATIONS")
- ]
- graph = ProofGraph(steps)
-
- # 3. Validate
- is_legal, reason = validate_pedagogical_legality(graph, rules)
-
- if is_legal:
- print(f"✅ Success: Pedagogy Guard correctly ALLOWED Grade 8 system solver.")
- else:
- print(f"❌ Failure: Pedagogy Guard REJECTED allowed operator. Reason: {reason}")
-
-def test_grade_11_calculus_whitelist():
- print("\n🧪 Testing Grade 11 Calculus (Derivative Allowed)...")
-
- # Grade 11 Rules
- rules = curriculum_engine.get_allowed_math_operators("11", "5")
-
- steps = [
- ProofStep(1, "f(x)=x^2", "Function"),
- ProofStep(2, "f'(x)=2x", "Derivative", operator_used="DERIVATIVE")
- ]
- graph = ProofGraph(steps)
-
- is_legal, reason = validate_pedagogical_legality(graph, rules)
-
- if is_legal:
- print("✅ Success: Grade 11 correctly allows DERIVATIVE.")
- else:
- print(f"❌ Failure: Grade 11 rejected allowed DERIVATIVE. {reason}")
-
-if __name__ == "__main__":
- try:
- test_grade_7_violation()
- test_grade_8_allowed()
- test_grade_11_calculus_whitelist()
- print("\n✨ All isolated pedagogical tests PASSED!")
- except Exception as e:
- print(f"\n💥 Test Execution Failed: {e}")
- sys.exit(1)
diff --git a/tests/archive_scripts/verify_v401_hotfix.py b/tests/archive_scripts/verify_v401_hotfix.py
deleted file mode 100644
index 41d15cbdfcc8d97d6714957781231cb02f4a521f..0000000000000000000000000000000000000000
--- a/tests/archive_scripts/verify_v401_hotfix.py
+++ /dev/null
@@ -1,39 +0,0 @@
-# verify_v401_hotfix.py
-import asyncio
-import sys
-import os
-import json
-
-# Add current directory to path
-sys.path.append(os.getcwd())
-
-from orchestrator import BuddyOrchestrator
-from prompts import get_data_extraction_prompt
-
-def test_single_letter_prompt_enforcement():
- print("🧪 Testing Data Anchor Prompt (V4.0.1)...")
- prompt = get_data_extraction_prompt("Solve: number_of_notebooks * 5 = 100")
- if "SINGLE-LETTER VARIABLE ONLY" in prompt.upper() or "V4.0.1" in prompt:
- print("✅ Success: Prompt contains V4.0.1 variable enforcement instructions.")
- else:
- print("❌ Failure: Prompt missing V4.0.1 instructions.")
-
-async def test_math_integrity_error_response():
- print("\n🧪 Testing Orchestrator Error Standardization...")
- orchestrator = BuddyOrchestrator()
-
- # We mock the case where smart_solver fails
- class MockResult:
- success = False
-
- # This is a bit tricky to test without full mock, but let's check the logic in smart_solve
- # We'll just verify the code structure in orchestrator.py manually or via a targeted mock if possible.
- # Since I just viewed the file and fixed the indentation, I'm confident in the code block.
-
- print("ℹ️ Visual verification of orchestrator.py:862-869 confirmed error object structure.")
- print("✅ Logic: return {'status': 'RECAPTURE_REQUIRED', 'type': 'error', ...}")
-
-if __name__ == "__main__":
- test_single_letter_prompt_enforcement()
- # asyncio.run(test_math_integrity_error_response())
- print("\n✨ V4.0.1 Basic Logic Verification PASSED!")
diff --git a/tests/archive_scripts/verify_v41_authority.py b/tests/archive_scripts/verify_v41_authority.py
deleted file mode 100644
index 61372e9b283e37b9b91000ef79bf781243ad6eef..0000000000000000000000000000000000000000
--- a/tests/archive_scripts/verify_v41_authority.py
+++ /dev/null
@@ -1,68 +0,0 @@
-# verify_v41_authority.py
-import sys
-import os
-import json
-
-# Add current directory to path
-sys.path.append(os.getcwd())
-
-from proof_graph import ProofGraph, ProofStep
-from pedagogical_builder import build_pedagogical_response
-import curriculum_engine
-
-def test_deterministic_merge():
- print("🧪 Testing Deterministic Merge (V4.1)...")
-
- # 1. Create a ProofGraph (The Truth)
- steps = [
- ProofStep(1, "x + 2 = 5", "שיוויון בסיסי"),
- ProofStep(2, "x = 3", "בידוד הנעלם")
- ]
- graph = ProofGraph(steps)
-
- # 2. Mock LLM output with different step counts or "distractions"
- llm_output = {
- "steps_explanations": [
- {"step_id": 1, "pedagogical_explanation": "נתחיל מהמשוואה"},
- {"step_id": 2, "pedagogical_explanation": "נחסר 2 משני האגפים"}
- ],
- "teacher_summary": "זה סיכום המורה"
- }
-
- # 3. Build response
- response = build_pedagogical_response("GENERAL", llm_output, {}, proof_graph=graph)
-
- # 4. Verify math is preserved from ProofGraph
- ui_steps = response["sections"][0]["steps"]
- if ui_steps[0]["block_math"] == "x + 2 = 5" and ui_steps[1]["block_math"] == "x = 3":
- print("✅ Success: Math preserved exactly from ProofGraph.")
- else:
- print(f"❌ Failure: Math mismatch. Found {ui_steps[0]['block_math']} and {ui_steps[1]['block_math']}")
-
-def test_curriculum_ontology_enforcement():
- print("\n🧪 Testing Curriculum Ontology Enforcement (V4.1)...")
-
- # Grade 7 rules
- rules = curriculum_engine.get_allowed_math_operators("י׳", level="3") # Example: 3 units might have restrictions
- grade7_rules = curriculum_engine.get_allowed_math_operators("ז׳", level="4")
-
- # Create an ILLEGAL ProofGraph for Grade 7 (System of Equations)
- illegal_steps = [
- ProofStep(1, "x + y = 10", "משוואה ראשונה"),
- ProofStep(2, "x - y = 2", "משוואה שנייה"),
- ProofStep(3, "2x = 12", "חיבור משוואות", operator_used="SYSTEM_OF_EQUATIONS")
- ]
- illegal_graph = ProofGraph(illegal_steps)
-
- from proof_graph import validate_pedagogical_legality
- is_legal, reason = validate_pedagogical_legality(illegal_graph, grade7_rules)
-
- if not is_legal and "SYSTEM_OF_EQUATIONS" in reason or "מערכת משוואות" in reason:
- print("✅ Success: Ontology-based guard REJECTED illegal operator for Grade 7.")
- else:
- print(f"❌ Failure: Guard allowed illegal operator or gave wrong reason: {reason}")
-
-if __name__ == "__main__":
- test_deterministic_merge()
- test_curriculum_ontology_enforcement()
- print("\n✨ V4.1 Truth Authority Logic Verification PASSED!")
diff --git a/tests/archive_scripts/verify_v428_strategies.py b/tests/archive_scripts/verify_v428_strategies.py
deleted file mode 100644
index 152fefa25b6b95a5e161578a37b33ebd91a2c41c..0000000000000000000000000000000000000000
--- a/tests/archive_scripts/verify_v428_strategies.py
+++ /dev/null
@@ -1,43 +0,0 @@
-import unittest
-from unittest.mock import MagicMock
-import sys
-import os
-
-# Mock dependencies to avoid loading the whole project
-sys.modules['ocr_strip_engine'] = MagicMock()
-sys.modules['explanation_math_firewall'] = MagicMock()
-sys.modules['visuals'] = MagicMock()
-sys.modules['audio_generator'] = MagicMock()
-sys.modules['gibberish_detector'] = MagicMock()
-sys.modules['cv2'] = MagicMock()
-
-import strategy_policy_engine
-import math_intent_detector
-
-class TestV428StrategyConstraint(unittest.TestCase):
- def test_policy_engine_geometry(self):
- intent = "GEOMETRY_ANALYTIC"
- vector = strategy_policy_engine.get_allowed_strategies(intent)
- self.assertIn("algebraic_intersection", vector["allowed"])
- self.assertIn("differentiation", vector["forbidden"])
-
- def test_policy_engine_linear(self):
- intent = "SIMPLE_LINEAR_SOLVE"
- vector = strategy_policy_engine.get_allowed_strategies(intent)
- self.assertIn("single_variable_isolation", vector["allowed"])
- self.assertIn("substitution_method", vector["forbidden"])
-
- def test_intent_merging_logic(self):
- # Simulation of the logic I added to orchestrator.py
- intent = "GEOMETRY_ANALYTIC"
- strategy_vector = strategy_policy_engine.get_allowed_strategies(intent)
- intent_contract = {"forbidden_strategies": ["guessing"]}
-
- if strategy_vector.get("forbidden"):
- intent_contract["forbidden_strategies"] = list(set(intent_contract.get("forbidden_strategies", []) + strategy_vector["forbidden"]))
-
- self.assertIn("differentiation", intent_contract["forbidden_strategies"])
- self.assertIn("guessing", intent_contract["forbidden_strategies"])
-
-if __name__ == '__main__':
- unittest.main()
diff --git a/tests/archive_scripts/verify_v42_firewall.py b/tests/archive_scripts/verify_v42_firewall.py
deleted file mode 100644
index 4d0c86c41e65749299f8e4045e77358f623e4f2e..0000000000000000000000000000000000000000
--- a/tests/archive_scripts/verify_v42_firewall.py
+++ /dev/null
@@ -1,87 +0,0 @@
-# verify_v42_firewall.py
-import sys
-import os
-import json
-
-# Add current directory to path
-sys.path.append(os.getcwd())
-
-from proof_graph import ProofGraph, ProofStep
-from pedagogical_builder import build_pedagogical_response
-import explanation_math_firewall
-
-def test_firewall_block():
- print("🧪 Testing Behavioral Firewall Block (V4.2)...")
-
- # 1. Create a simple ProofGraph (Algebra)
- steps = [ProofStep(1, "x + 2 = 5", "שיוויון בסיסי")]
- graph = ProofGraph(steps)
-
- # 2. Mock LLM output that injects a forbidden concept ("נגזרת")
- llm_output = {
- "steps_explanations": [
- {"step_id": 1, "pedagogical_explanation": "נשתמש במושג הנגזרת כדי למצוא את המשתנה"} # VIOLATION!
- ],
- "teacher_summary": "זה סיכום המורה"
- }
-
- # 3. Build response - should raise LLMSchemaError due to firewall
- try:
- build_pedagogical_response("GENERAL", llm_output, {}, proof_graph=graph)
- print("❌ Failure: Firewall ALLOWED a forbidden concept!")
- except Exception as e:
- if "Behavioral Firewall Violation" in str(e) and "נגזרת" in str(e):
- print(f"✅ Success: Firewall BLOCKED forbidden concept. Reason: {e}")
- else:
- print(f"❌ Failure: Got wrong error: {e}")
-
-def test_projection_only_lock():
- print("\n🧪 Testing Projection-Only Lock (V4.2)...")
-
- # ProofGraph with 1 step
- graph = ProofGraph([ProofStep(1, "x = 3", "תשובה סופית")])
-
- # LLM with NO steps
- llm_output = {}
-
- # Response should still have 1 step because it's a projection of the ProofGraph
- response = build_pedagogical_response("GENERAL", llm_output, {}, proof_graph=graph)
-
- ui_steps = response["sections"][0]["steps"]
- if len(ui_steps) == 1 and ui_steps[0]["block_math"] == "x = 3":
- print("✅ Success: UI is a projection of the ProofGraph even without LLM input.")
- else:
- print(f"❌ Failure: Projection mismatch. Steps found: {len(ui_steps)}")
-
-def test_deterministic_summary():
- print("\n🧪 Testing Deterministic Summary Renderer (V4.2)...")
- # This test simulates the orchestrator's summary generation
- from orchestrator import BuddyOrchestrator
-
- # We mock the parts needed for BuddyOrchestrator to run _generate_deterministic_summary
- class MockOrchestrator(BuddyOrchestrator):
- def __init__(self): pass # No need for real init
-
- orchestrator = MockOrchestrator()
-
- graph = ProofGraph([ProofStep(1, "x=3", "בידוד הנעלם", operator_used="BASIC_ALGEBRA")])
-
- summary = orchestrator._generate_deterministic_summary(
- "GENERAL",
- "משוואות",
- "x=3",
- proof_graph=graph
- )
-
- print(f"Generated Summary: {summary}")
-
- if "נבדק שהשתמשת בשיטות: בידוד הנעלם" in summary and "הגענו לתשובה: x=3" in summary:
- print("✅ Success: Deterministic summary followed the template correctly.")
- else:
- print("❌ Failure: Summary did not follow the template or missing data.")
-
-if __name__ == "__main__":
- test_firewall_block()
- test_projection_only_lock()
- test_deterministic_summary()
- print("\n✨ V4.2 Behavioral Firewall Verification PASSED!")
diff --git a/tests/archive_scripts/verify_v42_lockdown.py b/tests/archive_scripts/verify_v42_lockdown.py
deleted file mode 100644
index 2476452c12cfb7d2589107d43532df19fa1df05b..0000000000000000000000000000000000000000
--- a/tests/archive_scripts/verify_v42_lockdown.py
+++ /dev/null
@@ -1,103 +0,0 @@
-# verify_v42_lockdown.py
-import sys
-import os
-import asyncio
-import re
-from unittest.mock import MagicMock
-
-# Add current directory to path
-sys.path.append(os.getcwd())
-
-# Mock problematic dependencies before importing orchestrator
-sys.modules['cv2'] = MagicMock()
-sys.modules['ocr_strip_engine'] = MagicMock()
-
-import math_intent_detector
-from smart_solver import SmartSolver
-from strategy_manager import StrategyManager
-from orchestrator import seal_pedagogical_output
-
-async def test_grade7_lockdown():
- print("🧪 [V4.2] Testing Grade 7 Strategy Lockdown...")
-
- # 1. Intent Detection
- problem = "f(x) = 3x + 5. פתור את המשוואה עבור f(x)=0"
- grade_num = 7
- intent = math_intent_detector.detect_intent(problem, grade_num)
- contract = math_intent_detector.get_intent_contract(intent, grade_num)
-
- print(f"Detected Intent: {intent}")
-
- if intent != "SIMPLE_LINEAR_SOLVE":
- print("❌ Failure: Grade 7 problem not classified as SIMPLE_LINEAR_SOLVE")
- return
-
- # 2. Output Sealer Test (V4.2.4)
- print("\n🧪 [V4.2.4] Testing Global Output Sealer...")
- leaky_response = {
- "final_answer": "x = -1.66",
- "teacher_summary": "מצאנו את הנגזרת f'(x) וגילינו שזה קו ישר.",
- "sections": [{
- "steps": [{"content_mixed": "נחשב את הנגזרת f(x) = 3x+5..."}]
- }]
- }
-
- sealed = seal_pedagogical_output(leaky_response, 7)
- sealed_str = str(sealed)
-
- forbidden_terms = ["נגזרת", "f'(x)", "f(x)"]
- success = True
- for term in forbidden_terms:
- if term in sealed_str:
- print(f"❌ Failure: Forbidden term '{term}' still present in sealed output!")
- success = False
-
- if success:
- print("✅ Success: Global Sealer scrubbed all forbidden Grade 7 concepts.")
-
- # 3. StrategyManager Lockdown (Prompt Injection)
- print("\n🧪 [V4.2.4] Testing StrategyManager Narrative Contract...")
- mock_llm = MagicMock()
- captured_prompt = ""
-
- async def mock_generate(payload):
- nonlocal captured_prompt
- captured_prompt = payload[0] if isinstance(payload, list) else payload
- mock_resp = MagicMock()
- mock_resp.text = '{"steps": [{"step_id": 1, "pedagogical_explanation": "הסבר פשוט"}]}'
- return mock_resp
-
- mock_llm.generate_content_async = mock_generate
-
- manager = StrategyManager(mock_llm)
- await manager.solve_with_strategy(
- problem,
- {"function_equations": ["3*x + 5"]},
- grade="7",
- intent=intent,
- intent_contract=contract
- )
-
- if "### STRICT CONTRACT ###" in captured_prompt and "Narrative Projector" in captured_prompt:
- print("✅ Success: StrategyManager injected STRICT CONTRACT into narrative prompt.")
- else:
- print("❌ Failure: STRICT CONTRACT missing from narrative prompt.")
-
-def verify_infrastructure():
- print("\n🧪 [V4.2] Verifying Infrastructure Lockdown...")
- import config
- expected_bucket = "buddy-math-dev.firebasestorage.app"
- # Try different config structures
- bucket = getattr(config, 'STORAGE_BUCKET', None)
- if not bucket and hasattr(config, 'DevelopmentConfig'):
- bucket = getattr(config.DevelopmentConfig, 'STORAGE_BUCKET', None)
-
- if bucket == expected_bucket:
- print(f"✅ Success: Firebase Storage bucket locked to: {expected_bucket}")
- else:
- print(f"❌ Failure: Bucket mismatch! Found: {bucket}")
-
-if __name__ == "__main__":
- asyncio.run(test_grade7_lockdown())
- verify_infrastructure()
- print("\n✨ V4.2.4 Strategy Lockdown Verification COMPLETE!")
diff --git a/tests/inspector.py b/tests/inspector.py
deleted file mode 100644
index daaca8e43b18010a2b499bff85563e3e27a3b0ef..0000000000000000000000000000000000000000
--- a/tests/inspector.py
+++ /dev/null
@@ -1,57 +0,0 @@
-import requests
-import base64
-import json
-import time
-import os
-import sys
-
-# הגדרות
-API_URL = "http://127.0.0.1:8000/solve_stream"
-TEST_IMAGE_PATH = "test_image.png"
-
-def run_inspection_safe():
- print("🛑 SAFETY CHECK: This script will trigger 1 LLM call.")
- print(" Estimated cost: $0.0016")
- confirm = input(" Type 'yes' to proceed: ")
-
- if confirm.lower() != 'yes':
- print("❌ Aborted by user.")
- return
-
- # --- מכאן הקוד זהה לקודם, רץ פעם אחת בדיוק ---
- print("\n🚀 Starting Single Run Inspection...")
-
- if not os.path.exists(TEST_IMAGE_PATH):
- print(f"❌ Error: Image file '{TEST_IMAGE_PATH}' not found.")
- return
-
- files = {'file': ('test.png', open(TEST_IMAGE_PATH, 'rb'), 'image/png')}
- data = {'grade': 'Test', 'mode': 'solve', 'student_name': 'Tester'}
-
- try:
- start_time = time.time()
- # Timeout חובה! מונע המתנה אינסופית
- response = requests.post(API_URL, files=files, data=data, timeout=60)
-
- print(f"✅ Status Code: {response.status_code}")
- print(f"⏱️ Time: {time.time() - start_time:.2f}s")
-
- if response.status_code == 200:
- try:
- data = response.json()
- print("✅ JSON Valid")
- print(f"🔑 Keys found: {list(data.keys())}")
- if "sections" in data:
- print(f"📄 Sections count: {len(data['sections'])}")
- except:
- print("⚠️ Not a JSON response")
- print(response.text[:200])
- else:
- print("❌ Server Error")
- print(response.text)
-
- except Exception as e:
- print(f"❌ Connection Error: {e}")
-
-if __name__ == "__main__":
- run_inspection_safe()
\ No newline at end of file
diff --git a/tests/run_golden_set.py b/tests/run_golden_set.py
deleted file mode 100644
index 486c19424ebc0e92b56f027060bd8cbc90c66b84..0000000000000000000000000000000000000000
--- a/tests/run_golden_set.py
+++ /dev/null
@@ -1,123 +0,0 @@
-# tests/run_golden_set.py — V1.0 (Integration Test Runner)
-import asyncio
-import os
-import sys
-import json
-import logging
-import time
-
-# Add parent dir to path
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-
-# Import core components
-try:
- from orchestrator import BuddyOrchestrator
-except ImportError as e:
- print(f"❌ Error importing orchestrator: {e}")
- sys.exit(1)
-
-# Configure logging to be minimal for the runner
-logging.basicConfig(level=logging.ERROR)
-logger = logging.getLogger("GOLDEN_SET")
-
-GOLDEN_SET_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "qa_golden_set")
-
-async def run_single_test(orchestrator, filename):
- filepath = os.path.join(GOLDEN_SET_DIR, filename)
- print(f"🔬 Testing: {filename}...", end=" ", flush=True)
-
- with open(filepath, "rb") as f:
- image_data = f.read()
-
- start_time = time.time()
- try:
- # V277.0: FIXED - solve_problem is now an async generator.
- # We must iterate over it and collect the final COMPLETE payload.
- final_result = {}
- async for event in orchestrator.solve_problem(
- problem_text="[GOLDEN_SET_AUTO_OCR]",
- grade="י'",
- student_name="QA_BOT",
- image_data=image_data
- ):
- # V8.5 logic: If the state is COMPLETE, the payload contains the full standard response
- if event.state == "COMPLETE" and event.payload:
- final_result = event.payload
- elif event.state == "ERROR":
- final_result = event.payload or {"logic_error": True, "status": "ERROR"}
-
- result = final_result
- duration = time.time() - start_time
-
- status = result.get("status", "SUCCESS")
- logic_error = result.get("logic_error", False)
- error_type = result.get("error_type", "NONE")
-
- icon = "✅"
- if logic_error:
- icon = f"❌ LOGIC_ERROR ({error_type})"
- elif not result:
- icon = "🛑 EMPTY_RESULT"
- logic_error = True
- elif status != "SUCCESS":
- icon = f"⚠️ {status}"
- else:
- # 🚨 [SOP FIX] Ensure sections are not empty on Happy Path
- sections = result.get("sections", [])
- if not sections:
- icon = "⚠️ EMPTY_SECTIONS"
- # We don't necessarily fail the whole test if it's a simple answer only,
- # but for the Golden Set, we expect sections.
-
- print(f"{icon} ({duration:.1f}s)")
-
- return {
- "file": filename,
- "status": status,
- "logic_error": logic_error,
- "error_type": error_type,
- "duration": round(duration, 1),
- "final_answer": str(result.get("final_answer", ""))[:100] + "..."
- }
- except Exception as e:
- print(f"💥 CRASH: {e}")
- return {
- "file": filename,
- "status": "CRASH",
- "logic_error": True,
- "error_type": "CRASH",
- "duration": 0,
- "final_answer": str(e)
- }
-
-async def main():
- if not os.path.exists(GOLDEN_SET_DIR):
- print(f"❌ Directory not found: {GOLDEN_SET_DIR}")
- return
-
- orchestrator = BuddyOrchestrator()
- files = [f for f in os.listdir(GOLDEN_SET_DIR) if f.lower().endswith(('.jpg', '.jpeg', '.png'))]
- files.sort()
-
- print(f"\n🚀 Starting QA Golden Set Validation (10 images)")
- print("=" * 60)
-
- results = []
- for f in files:
- res = await run_single_test(orchestrator, f)
- results.append(res)
- # Sleep briefly between tests to avoid hitting quotas too hard
- await asyncio.sleep(2)
-
- print("=" * 60)
- print(f"🏁 Finished. Success: {len([r for r in results if not r['logic_error']])}/{len(results)}")
-
- # Save a JSON report
- report_path = os.path.join(os.path.dirname(GOLDEN_SET_DIR), "temp", "golden_set_report.json")
- os.makedirs(os.path.dirname(report_path), exist_ok=True)
- with open(report_path, "w", encoding="utf-8") as rf:
- json.dump(results, rf, indent=2, ensure_ascii=False)
- print(f"📊 Report saved to: {report_path}\n")
-
-if __name__ == "__main__":
- asyncio.run(main())
diff --git a/tests/test_adaptive_failure.py b/tests/test_adaptive_failure.py
deleted file mode 100644
index d9b3fdae0ac570e84d5bfa42c6e4060c506165ab..0000000000000000000000000000000000000000
--- a/tests/test_adaptive_failure.py
+++ /dev/null
@@ -1,59 +0,0 @@
-import asyncio
-import unittest
-from unittest.mock import MagicMock, AsyncMock
-from orchestrator import BuddyOrchestrator
-from config import CONFIDENCE_THRESHOLD_HIGH, CONFIDENCE_THRESHOLD_MEDIUM
-
-class TestAdaptiveFailureMode(unittest.IsolatedAsyncioTestCase):
- def setUp(self):
- self.orchestrator = BuddyOrchestrator()
- # Mock strategy manager
- self.orchestrator.strategy_manager = MagicMock()
- self.orchestrator.strategy_manager.solve_with_strategy = AsyncMock()
-
- # Mock internal methods to isolate orchestration logic
- self.orchestrator._classify_question = AsyncMock(return_value={"complexity": "COMPLEX", "num_parts": 1})
- self.orchestrator._extract_key_data = AsyncMock(return_value={})
- self.orchestrator.smart_solve = AsyncMock(return_value={"sections": []})
-
- async def test_high_confidence_flow(self):
- print(f"\n🟢 Testing High Confidence (> {CONFIDENCE_THRESHOLD_HIGH}):")
- self.orchestrator._last_ocr_confidence = CONFIDENCE_THRESHOLD_HIGH + 0.05
- await self.orchestrator.solve_problem("x + 1 = 2 and we need to solve it step by step for the student", "י'", "TestStudent")
-
- # Verify smart_solve was called with ambiguity_warning=False
- args, kwargs = self.orchestrator.smart_solve.call_args
- self.assertFalse(kwargs['ambiguity_warning'])
- print(f" ✅ High Confidence {self.orchestrator._last_ocr_confidence} - Passed (No warning)")
-
- async def test_medium_confidence_soft_recovery(self):
- print(f"\n🟡 Testing Medium Confidence ({CONFIDENCE_THRESHOLD_MEDIUM} - {CONFIDENCE_THRESHOLD_HIGH}):")
- # Ensure we are in the medium range
- self.orchestrator._last_ocr_confidence = (CONFIDENCE_THRESHOLD_HIGH + CONFIDENCE_THRESHOLD_MEDIUM) / 2
-
- # Force escalation to smart_solve by mocking quick_solve failure
- self.orchestrator._quick_solve = AsyncMock(return_value=None)
-
- await self.orchestrator.solve_problem("x + 1 = 2 and we need to solve it step by step for the student", "י'", "TestStudent")
-
- # Verify smart_solve was called with ambiguity_warning=True
- args, kwargs = self.orchestrator.smart_solve.call_args
- self.assertTrue(kwargs['ambiguity_warning'])
- print(f" ✅ Medium Confidence {self.orchestrator._last_ocr_confidence} - Passed (Soft Recovery triggered)")
-
- async def test_low_confidence_hard_stop(self):
- # We need to make sure we are BELOW CONFIDENCE_THRESHOLD_MEDIUM
- # In DEV it is 0.01, so let's use 0.005
- low_val = min(0.005, CONFIDENCE_THRESHOLD_MEDIUM - 0.001)
- print(f"\n🔴 Testing Low Confidence (< {CONFIDENCE_THRESHOLD_MEDIUM}):")
- self.orchestrator._last_ocr_confidence = low_val
- result = await self.orchestrator.solve_problem("x + 1 = 2 and we need to solve it step by step for the student", "י'", "TestStudent")
-
- # V7.3: Check logic_error and error_type instead of 'status'
- self.assertTrue(result.get("logic_error"))
- self.assertEqual(result.get("error_type"), "RECAPTURE_REQUIRED")
- self.orchestrator.smart_solve.assert_not_called()
- print(f" ✅ Low Confidence {self.orchestrator._last_ocr_confidence} - Passed (Hard Stop triggered)")
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_ce_fixes.py b/tests/test_ce_fixes.py
deleted file mode 100644
index f1936e18b25142d47234cb5d242981a21970f123..0000000000000000000000000000000000000000
--- a/tests/test_ce_fixes.py
+++ /dev/null
@@ -1,55 +0,0 @@
-# tests/test_ce_fixes.py
-import asyncio
-import os
-import sys
-import logging
-import time
-
-# Add parent dir to path
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-
-from orchestrator import BuddyOrchestrator
-
-async def test_ce_fixes():
- orchestrator = BuddyOrchestrator()
-
- # 03_ocr_only.jpeg is the target for geometry bypass and truth authority bypass check
- filename = "03_ocr_only.jpeg"
- filepath = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "qa_golden_set", filename)
-
- if not os.path.exists(filepath):
- print(f"❌ File not found: {filepath}")
- return
-
- print(f"🔬 Testing CE Fixes with: {filename}...")
-
- with open(filepath, "rb") as f:
- image_data = f.read()
-
- start_time = time.time()
- try:
- result = await orchestrator.solve_problem(
- problem_text="[CE_FIXES_TEST]",
- grade="י'",
- student_name="QA_BOT",
- image_data=image_data
- )
- duration = time.time() - start_time
-
- logic_error = result.get("logic_error", False)
- error_type = result.get("error_type", "NONE")
-
- if logic_error:
- print(f"❌ FAILED: logic_error=True, error_type={error_type}")
- print(f"Final Answer: {result.get('final_answer')}")
- else:
- print(f"✅ SUCCESS! (Duration: {duration:.1f}s)")
- print(f"Final Answer: {result.get('final_answer')[:100]}...")
- sections = result.get("sections", [])
- print(f"Sections found: {len(sections)}")
-
- except Exception as e:
- print(f"💥 CRASH: {e}")
-
-if __name__ == "__main__":
- asyncio.run(test_ce_fixes())
diff --git a/tests/test_polygraph_gate.py b/tests/test_polygraph_gate.py
deleted file mode 100644
index 1efbb552bfc03ecfc5ed07682cfaf62c9717c067..0000000000000000000000000000000000000000
--- a/tests/test_polygraph_gate.py
+++ /dev/null
@@ -1,146 +0,0 @@
-# tests/test_polygraph_gate.py — V1.0 (QA GATE for MathPolygraph)
-"""
-CTO Directive: Automated QA gate for MathPolygraph.validate_step_sequence.
-
-Definition of Done (must all pass before merge):
- 1. SymPy parse failure → Fail-Closed (False returned, never swallowed)
- 2. 3-second timeout fires on a heavy expression
- 3. Geometry pipe-separated result passes through (no false positive)
- 4. Hebrew-only field is skipped cleanly
- 5. *** Happy Path: a valid algebraic sequence is NOT blocked ***
-"""
-
-import concurrent.futures
-import sys
-import os
-import unittest
-
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-from domain.math_validator import MathPolygraph
-
-
-# ─────────────────────────────────────────────────────────────────────────────
-# Helpers
-# ─────────────────────────────────────────────────────────────────────────────
-
-def _step(step_id: int, math_latex: str) -> dict:
- """Build a minimal step dict accepted by MathPolygraph."""
- return {"step_id": step_id, "math_latex": math_latex}
-
-
-# ─────────────────────────────────────────────────────────────────────────────
-# Test Cases
-# ─────────────────────────────────────────────────────────────────────────────
-
-class TestPolygraphGate(unittest.TestCase):
-
- # ── 1. SymPy parse failure → Fail-Closed ──────────────────────────────────
- def test_gibberish_math_is_fail_closed(self):
- """An expression SymPy cannot parse must return (False, ...)."""
- steps = [_step(1, "x**1000@#invalid!$%^")]
- ok, reason = MathPolygraph.validate_step_sequence(steps)
- self.assertFalse(ok, msg=f"Expected Fail-Closed but got ok=True. reason={reason}")
- self.assertIn("SYMPY_PARSING_FAILED", reason, msg=f"Unexpected reason: {reason}")
- print(f"✅ [1] Gibberish fail-closed: {reason}")
-
- # ── 2. 3-second timeout fires on heavy expression ─────────────────────────
- def test_timeout_fires(self):
- """
- A very heavy expression must be BLOCKED by the Polygraph gate,
- guaranteeing it never hangs the server indefinitely.
-
- CTO guarantee: fail-closed, never hang. The *specific* error code
- (SYMPY_TIMEOUT vs SYMPY_UNEXPECTED_ERROR) depends on whether the
- expression hits the 3-second ThreadPoolExecutor deadline or triggers
- an earlier recursion/memory error in SymPy — both are valid fail-closed
- outcomes.
- """
- heavy = "(" + "+".join(f"x**{i}" for i in range(500)) + ")"
- steps = [_step(1, heavy)]
- ok, reason = MathPolygraph.validate_step_sequence(steps)
- if not ok:
- # Gate blocked correctly — accept any failure reason
- self.assertFalse(ok)
- print(f"✅ [2] Heavy expression blocked (fail-closed): reason='{reason[:60]}'")
- else:
- # SymPy was fast enough on this machine — no hang, no block needed
- print(f"ℹ️ [2] Heavy expression parsed within 3s on this machine. ok={ok}. Acceptable.")
-
- def test_timeout_reason_via_mock(self):
- """
- Verifies the SYMPY_TIMEOUT code path in isolation using a mock,
- without depending on machine speed.
- """
- from unittest.mock import patch
- import concurrent.futures
-
- # Force _sympify_with_timeout to raise TimeoutError
- with patch.object(
- MathPolygraph,
- "_sympify_with_timeout",
- side_effect=concurrent.futures.TimeoutError,
- ):
- ok, reason = MathPolygraph.validate_step_sequence([_step(1, "x+1")])
- self.assertFalse(ok, msg="Expected Fail-Closed on TimeoutError")
- self.assertIn("SYMPY_TIMEOUT", reason, msg=f"Unexpected reason: {reason}")
- print(f"✅ [2b] Mocked timeout returned correct reason: {reason}")
-
- # ── 3. Geometry pipe-separated result passes (no false positive) ──────────
- def test_geometry_pipe_result_passes(self):
- """Pipe-separated geometry labels must not be blocked."""
- steps = [_step(1, "(0, 5) | (3, 0)")]
- ok, reason = MathPolygraph.validate_step_sequence(steps)
- self.assertTrue(ok, msg=f"Geometry pipe result was unexpectedly blocked. reason={reason}")
- print(f"✅ [3] Geometry pipe-result passed: ok={ok}")
-
- # ── 4. Hebrew-only field is skipped ───────────────────────────────────────
- def test_hebrew_only_skips_sympy(self):
- """Hebrew-only math fields must be accepted without calling SymPy."""
- steps = [_step(1, "על המעגל")]
- ok, reason = MathPolygraph.validate_step_sequence(steps)
- self.assertTrue(ok, msg=f"Hebrew-only field was unexpectedly blocked. reason={reason}")
- print(f"✅ [4] Hebrew-only field skipped SymPy: ok={ok}")
-
- # ── 5. HAPPY PATH: valid algebraic sequence is NOT blocked ────────────────
- def test_happy_path_valid_sequence_passes(self):
- """
- CTO requirement: A perfectly valid solution must never be blocked.
-
- x**2 = 4 → x = 2
- These are not algebraically equivalent (by design — step B is a solution
- of step A, not a simplification), so the equivalence check logs an info
- but does NOT fail. Both expressions must be SymPy-parseable, guaranteeing
- a (True, "") return.
- """
- steps = [
- _step(1, "x**2 - 4"), # x² = 4 (normalized to x²-4=0)
- _step(2, "x - 2"), # x = 2 (normalized to x-2=0)
- ]
- ok, reason = MathPolygraph.validate_step_sequence(steps)
- self.assertTrue(ok, msg=f"Happy Path was unexpectedly BLOCKED! This would break the system. reason={reason}")
- print(f"✅ [5] Happy Path valid sequence passed: ok={ok}")
-
- # ── 6. Empty steps list passes (no-op) ───────────────────────────────────
- def test_empty_steps_passes(self):
- """Empty list should always return (True, '') — no steps to check."""
- ok, reason = MathPolygraph.validate_step_sequence([])
- self.assertTrue(ok)
- self.assertEqual(reason, "")
- print(f"✅ [6] Empty steps list: ok={ok}")
-
- # ── 7. Step with no math field is silently skipped ────────────────────────
- def test_step_with_no_math_field_skips(self):
- """Steps without any math field must not cause failures."""
- steps = [{"step_id": 1, "explanation_text": "נבצע את החישוב"}]
- ok, reason = MathPolygraph.validate_step_sequence(steps)
- self.assertTrue(ok, msg=f"Step with no math field was unexpectedly blocked. reason={reason}")
- print(f"✅ [7] Step with no math field skipped: ok={ok}")
-
-
-# ─────────────────────────────────────────────────────────────────────────────
-# Runner
-# ─────────────────────────────────────────────────────────────────────────────
-
-if __name__ == "__main__":
- print("\n🔬 [POLYGRAPH QA GATE] Running all tests...\n")
- unittest.main(verbosity=2)
diff --git a/tests/test_validation_robustness.py b/tests/test_validation_robustness.py
deleted file mode 100644
index e8344305559cbe0bfe7ecd6094a3ceb74dc99d3b..0000000000000000000000000000000000000000
--- a/tests/test_validation_robustness.py
+++ /dev/null
@@ -1,84 +0,0 @@
-import pytest
-import asyncio
-from domain.math_validator import MathPolygraph, _latex_to_sympy_str
-import sympy
-
-@pytest.mark.asyncio
-async def test_blind_stripping_and_regex():
- # Test that mixed text is preserved and math is extracted
- text = "נציב $x=5$ ונקבל $y=2$. התוצאה היא $$z=7$$."
- # _validate_single should return True because all math segments are valid
- ok, reason = await MathPolygraph._validate_single(text, 1)
- assert ok, f"Failed mixed text: {reason}"
-
-@pytest.mark.asyncio
-async def test_multiline_display_math():
- # Test re.DOTALL with multi-line display math
- text = """הנה משוואה:
- $$
- x = 5 + 3
- y = 10
- $$
- סוף."""
- ok, reason = await MathPolygraph._validate_single(text, 1)
- assert ok, f"Failed multiline display math: {reason}"
-
-@pytest.mark.asyncio
-async def test_multi_equal_guard():
- # x=y=5 should not crash unpacking
- text = "נתון $x = y = 5$."
- ok, reason = await MathPolygraph._validate_single(text, 1)
- assert ok, f"Failed multi-equal check: {reason}"
-
-@pytest.mark.asyncio
-async def test_empty_string_guard():
- # $$$$ should be ignored
- text = "ריק $$$$ וגם $ $."
- ok, reason = await MathPolygraph._validate_single(text, 1)
- assert ok, f"Failed empty string guard: {reason}"
-
-@pytest.mark.asyncio
-async def test_arithmetic_exception_handling():
- # $1/0$ should not crash the server
- text = "חלוקה באפס $1/0$."
- ok, reason = await MathPolygraph._validate_single(text, 1)
- assert not ok
- assert "SYMPY_PARSE_ERROR" in reason
-
-@pytest.mark.asyncio
-async def test_variable_trap_in_equivalence():
- # Algebraic equivalence should pass syntax check but skip numerical identity
- # x=5 is validated segment by segment (LHS: x, RHS: 5)
- # are_equivalent for x=5 and x=5 should return True
- res = MathPolygraph.are_equivalent("x=5", "x=5")
- assert res is True
-
-@pytest.mark.asyncio
-async def test_numerical_identity_check():
- # Valid identity
- assert MathPolygraph.are_equivalent("2+3", "5") is True
- # Hallucination
- assert MathPolygraph.are_equivalent("2+3", "6") is False
-
-@pytest.mark.asyncio
-async def test_inequality_guard():
- # x > 0 should not crash simplify(LHS - RHS)
- # It should skip identity check and return True because strings are same
- assert MathPolygraph.are_equivalent("x > 0", "x > 0") is True
-
-@pytest.mark.asyncio
-async def test_rce_protection():
- # Malicious string that would execute if sympify was used without evaluate=False
- # However, parse_expr(evaluate=False) just builds the tree.
- # We just want to ensure it doesn't crash or execute.
- # Note: testing "exec" in a string is hard without side effects,
- # but we can verify it doesn't crash on standard malicious patterns.
- text = "__import__('os').system('echo hello')"
- # This should definitely fail parsing or at least not execute
- ok, reason = await MathPolygraph._check_segment(text, 1)
- assert not ok
- assert "SYMPY_PARSE_ERROR" in reason
-
-if __name__ == "__main__":
- import pytest
- pytest.main([__file__])
diff --git a/tests/verify_core_v11.py b/tests/verify_core_v11.py
deleted file mode 100644
index 682adbf6a6ac367ffdf96607b5e33ceaa141f2a5..0000000000000000000000000000000000000000
--- a/tests/verify_core_v11.py
+++ /dev/null
@@ -1,89 +0,0 @@
-import json
-import asyncio
-import numpy as np
-from ocr_strip_engine import get_best_sniper_roi, apply_conditional_homography
-from proof_graph import ProofGraph, ProofStep
-from math_sanitizer import ProductionMathSanitizer
-from pedagogical_builder import build_pedagogical_response
-
-async def verify_core_v11():
- print("🚀 Starting BuddyMath Core V1.1 Verification Suite")
-
- # 1. Test Layer 1: Vision & Scene Intelligence
- print("\n👁️ Testing Layer 1: Vision Architecture")
- mock_img = np.zeros((1000, 1000, 3), dtype=np.uint8)
- # Simulate a dense math block (white pixels)
- mock_img[100:150, 200:400] = 255
-
- roi, confidence = get_best_sniper_roi(mock_img)
- print(f" - ROI Confidence (Heatmap Prior): {confidence:.2f}")
- assert confidence > 0.1, "Heatmap prior scoring failure"
-
- homography_result = apply_conditional_homography(roi)
- print(" - Conditional Homography applied successfully.")
- assert homography_result.shape[0] > 0, "Homography returned empty image"
-
- # 2. Test Layer 2: Math Safety Lock
- print("\n🔒 Testing Layer 2: Math Safety Lock")
- proof_steps = [
- ProofStep(1, "f(x) = x^2", "Initial Function"),
- ProofStep(2, "f'(x) = 2x", "Derivative Step")
- ]
- pg = ProofGraph(proof_steps)
- bridge = ProductionMathSanitizer.get_symbolic_bridge(pg)
- print(" - Symbolic Bridge generated:")
- print(f" {bridge.splitlines()[3]}") # Show one line
- assert "VERIFIED SYMBOLIC BRIDGE" in bridge
- assert "f(x) = x^2" in bridge
-
- sanitized = ProductionMathSanitizer.normalize_latex(r"\frac{x}{2}")
- print(f" - Math Sanitizer (Fraction): {sanitized}")
- assert sanitized == "(x)/(2)"
-
- # 3. Test Layer 3: Pedagogical Engine (LOPA)
- print("\n🎓 Testing Layer 3: Pedagogical Engine")
- mock_llm_output = {
- "solution_markdown": "Test solution with steps.",
- "confidence_score": 0.9,
- "sections": [
- {
- "title": "Steps",
- "steps": [
- {"title": "Step 1"}, {"title": "Step 2"},
- {"title": "Step 3"}, {"title": "Step 4"}
- ]
- }
- ]
- }
-
- response = build_pedagogical_response("GENERAL", mock_llm_output, {})
- steps = response['sections'][0]['steps']
- print(f" - Cognitive Load Limiter (Step Count): {len(steps)}")
-
- visible_count = sum(1 for s in steps if s.get("disclosure_state") == "VISIBLE")
- hidden_count = sum(1 for s in steps if s.get("disclosure_state") == "HIDDEN")
- print(f" - Visible: {visible_count}, Hidden: {hidden_count}")
-
- # Priority bypass (from previous hotfix) might return 1 step, but let's check the logic
- # Actually, build_pedagogical_response with template usually has more steps.
- # But wait, if llm_output HAS solution_markdown, it returns 1 step.
- # Let's test with a template-style output (no solution_markdown)
- mock_template_output = {
- "confidence_score": 0.9,
- "sections": [
- {
- "title": "Steps",
- "steps": [{"t":1}, {"t":2}, {"t":3}, {"t":4}, {"t":5}]
- }
- ]
- }
- response2 = build_pedagogical_response("LINEAR_EQUATION", mock_template_output, {})
- steps2 = response2['sections'][0]['steps']
- visible_count2 = sum(1 for s in steps2 if s.get("disclosure_state") == "VISIBLE")
- print(f" - Template disclosure: {visible_count2} visible")
- assert visible_count2 <= 3, "Cognitive Load Limiter failed to hide steps"
-
- print("\n🏆 BuddyMath Core V1.1 Verified!")
-
-if __name__ == "__main__":
- asyncio.run(verify_core_v11())
diff --git a/tests/verify_imports.py b/tests/verify_imports.py
deleted file mode 100644
index 49687874029948ea4af03b876429cc497a546604..0000000000000000000000000000000000000000
--- a/tests/verify_imports.py
+++ /dev/null
@@ -1,201 +0,0 @@
-"""
-V231.17 - Lightweight verification script
-Tests imports, syntax, and function signatures WITHOUT needing Google API key.
-"""
-import sys, os
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-
-passed = 0
-failed = 0
-
-def check(name, condition, detail=""):
- global passed, failed
- if condition:
- print(f" ✅ {name}")
- passed += 1
- else:
- print(f" ❌ {name} - {detail}")
- failed += 1
-
-print("=" * 60)
-print("🔬 BuddyMath V231.17 - Local Verification")
-print("=" * 60)
-
-# =========== 1. IMPORTS ===========
-print("\n📦 1. Import Tests:")
-
-try:
- import json
- check("json (stdlib)", True)
-except Exception as e:
- check("json (stdlib)", False, str(e))
-
-try:
- import json_repair
- check("json_repair", True)
-except Exception as e:
- check("json_repair", False, str(e))
-
-try:
- import micro_prompts
- check("micro_prompts imports", True)
-except Exception as e:
- check("micro_prompts imports", False, str(e))
-
-try:
- import problem_understanding
- check("problem_understanding imports", True)
-except Exception as e:
- check("problem_understanding imports", False, str(e))
-
-try:
- import topic_taxonomy
- check("topic_taxonomy imports", True)
-except Exception as e:
- check("topic_taxonomy imports", False, str(e))
-
-try:
- import validators
- check("validators imports", True)
-except Exception as e:
- check("validators imports", False, str(e))
-
-try:
- import prompts
- check("prompts imports", True)
-except Exception as e:
- check("prompts imports", False, str(e))
-
-# =========== 2. MICRO_PROMPTS ===========
-print("\n📝 2. micro_prompts.py Tests:")
-
-try:
- # Test that json is available inside micro_prompts
- result = micro_prompts.get_general_prompt({"test": "data"})
- check("get_general_prompt() works (json.dumps OK)", isinstance(result, str) and len(result) > 20)
-except NameError as e:
- check("get_general_prompt() works", False, f"NameError: {e}")
-except Exception as e:
- check("get_general_prompt() works", False, str(e))
-
-try:
- # Test CIRCLE_EQUATION micro-prompt
- result = micro_prompts.get_micro_prompt("CIRCLE_EQUATION", {"center": "(3,5)", "radius": "5"})
- check("CIRCLE_EQUATION micro-prompt", isinstance(result, str) and "מעגל" in result)
-except Exception as e:
- check("CIRCLE_EQUATION micro-prompt", False, str(e))
-
-try:
- tokens = micro_prompts.get_prompt_token_count("CIRCLE_EQUATION")
- check(f"Token count for CIRCLE_EQUATION = {tokens}", tokens > 0)
-except Exception as e:
- check("Token count", False, str(e))
-
-# =========== 3. PROBLEM UNDERSTANDING ===========
-print("\n🧠 3. problem_understanding.py Tests:")
-
-try:
- prompt = problem_understanding.get_problem_understanding_prompt(
- "מצא משוואת מעגל", {"function_equations": [], "points": ["A"]}
- )
- check("Understanding prompt generation", isinstance(prompt, str) and len(prompt) > 50)
-except Exception as e:
- check("Understanding prompt generation", False, str(e))
-
-try:
- # Test JSON parsing with LaTeX - THE BUG WE FIXED
- test_json_with_latex = '{"problem_type": "GEOMETRY", "sub_questions": [{"id": "א", "topic": "CIRCLE"}]}'
- result = problem_understanding.parse_understanding(test_json_with_latex)
- check("parse_understanding (clean JSON)", result["problem_type"] == "GEOMETRY")
-except Exception as e:
- check("parse_understanding (clean JSON)", False, str(e))
-
-try:
- # Test with escaped LaTeX that breaks standard json.loads
- test_bad_json = '{"type": "GEOMETRY", "eq": "\\Delta MOA"}'
- result = problem_understanding.parse_understanding(test_bad_json)
- check("parse_understanding (LaTeX JSON - json_repair)", "type" in result)
-except Exception as e:
- check("parse_understanding (LaTeX JSON)", False, str(e))
-
-try:
- valid = problem_understanding.validate_understanding({
- "problem_type": "CIRCLE",
- "sub_questions": [{"id": "א", "question": "test", "topic": "CIRCLE"}],
- "solving_order": ["א"]
- })
- check("validate_understanding", valid == True)
-except Exception as e:
- check("validate_understanding", False, str(e))
-
-# =========== 4. STRATEGY MANAGER ===========
-print("\n🎯 4. strategy_manager.py Tests:")
-
-try:
- from strategy_manager import StrategyManager
- check("StrategyManager class imports", True)
-except Exception as e:
- check("StrategyManager class imports", False, str(e))
-
-try:
- import inspect
- sig = inspect.signature(StrategyManager.solve_with_strategy)
- params = list(sig.parameters.keys())
- check(f"solve_with_strategy params: {params}", "image_data" in params)
-except Exception as e:
- check("solve_with_strategy signature", False, str(e))
-
-try:
- sig = inspect.signature(StrategyManager._call_llm)
- params = list(sig.parameters.keys())
- check(f"_call_llm params: {params}", "image_data" in params and "category" in params)
-except Exception as e:
- check("_call_llm signature", False, str(e))
-
-# =========== 5. ORCHESTRATOR (without API key) ===========
-print("\n🏗️ 5. orchestrator.py Tests:")
-
-try:
- # Read the file and check for the kwarg fix
- with open("orchestrator.py", "r", encoding="utf-8") as f:
- orch_code = f.read()
-
- check("orchestrator.py reads image_bytes from kwargs",
- "image_bytes" in orch_code and "kwargs.get('image_bytes'" in orch_code)
-except Exception as e:
- check("orchestrator.py kwarg check", False, str(e))
-
-try:
- check("orchestrator.py passes image_data to smart_solve",
- "image_data=image_data" in orch_code)
-except Exception as e:
- check("orchestrator image_data pass-through", False, str(e))
-
-try:
- check("orchestrator.py has V273.0+ version",
- "V273.0" in orch_code)
-except Exception as e:
- check("orchestrator version", False, str(e))
-
-# =========== 6. MAIN.PY ===========
-print("\n🌐 6. main.py Tests:")
-
-try:
- with open("main.py", "r", encoding="utf-8") as f:
- main_code = f.read()
-
- check("main.py sends image_bytes to solve_problem",
- "image_bytes=image_bytes" in main_code)
-except Exception as e:
- check("main.py image_bytes check", False, str(e))
-
-# =========== SUMMARY ===========
-print("\n" + "=" * 60)
-total = passed + failed
-print(f"📊 Results: {passed}/{total} passed, {failed}/{total} failed")
-if failed == 0:
- print("🎉 ALL TESTS PASSED! Ready for deploy.")
-else:
- print(f"⚠️ {failed} test(s) FAILED - fix before deploy!")
-print("=" * 60)
diff --git a/tests/verify_pipeline.py b/tests/verify_pipeline.py
deleted file mode 100644
index 6487b132dee9e66311fe4bbc814cf580bd2c7cbe..0000000000000000000000000000000000000000
--- a/tests/verify_pipeline.py
+++ /dev/null
@@ -1,65 +0,0 @@
-import asyncio
-import os
-import sys
-import json
-from dotenv import load_dotenv
-
-# Add parent directory to path so we can import modules
-sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-
-from buddy_math_server.orchestrator import BuddyOrchestrator
-
-# Mock image data (1x1 pixel transparent gif)
-MOCK_IMAGE = b'GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff!\xf9\x04\x01\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x01D\x00;'
-
-async def test_pipeline():
- print("🚀 Starting Backend Pipeline Verification...")
-
- # Load env
- load_dotenv()
- if not os.getenv("GOOGLE_API_KEY"):
- print("❌ GOOGLE_API_KEY not found in environment!")
- return
-
- try:
- # Initialize Orchestrator
- print("🔹 Initializing BuddyOrchestrator...")
- orchestrator = BuddyOrchestrator()
-
- # Test Case 1: Geometry Problem (Should trigger Image path)
- print("\n🧪 Test Case 1: Geometry Problem with Image")
- problem_text = """
- במשולש ישר זווית ABC, היתר הוא 10 ס"מ.
- א. מצא את אורך הניצב אם הניצב השני הוא 6.
- ב. חשב את שטח המשולש.
- """
-
- # Simulate the call from main.py -> solve_problem
- # passing image_data as kwargs
- print("🔹 Calling solve_problem...")
- result = await orchestrator.solve_problem(
- problem_text=problem_text,
- grade="10",
- student_name="TestUser",
- student_gender="M",
- image_data=MOCK_IMAGE
- )
-
- print("\n✅ Pipeline Execution Successful!")
- print("📄 Result Keys:", result.keys())
-
- if "sections" in result:
- print(f"✅ Generated {len(result['sections'])} sections")
- for s in result['sections']:
- print(f" - {s.get('section_title')}")
- else:
- print("⚠️ No sections found in result (might be legacy fallback?)")
- print("Result dump:", json.dumps(result, ensure_ascii=False)[:200] + "...")
-
- except Exception as e:
- print(f"\n❌ Pipeline Verified FAILED: {e}")
- import traceback
- traceback.print_exc()
-
-if __name__ == "__main__":
- asyncio.run(test_pipeline())
diff --git a/tests/verify_swiss_watch.py b/tests/verify_swiss_watch.py
deleted file mode 100644
index 5c6759a839969c4eaaeb79d3bf93d3638d69ed9e..0000000000000000000000000000000000000000
--- a/tests/verify_swiss_watch.py
+++ /dev/null
@@ -1,67 +0,0 @@
-import json
-from pedagogical_builder import merge_and_verify_explanations, LLMSchemaError
-
-def test_golden_merge():
- print("🚀 Running The Golden Test: V2.5.3 Swiss Watch")
-
- # 1. Mock SymPy Truth Nodes (Immutable)
- sympy_nodes = [
- {"step_id": 1, "math": "f(x) = x^2", "logic": "מבנה הפונקציה"},
- {"step_id": 2, "math": "f'(x) = 2x", "logic": "גזירה"}
- ]
-
- # 2. Mock LLM Pedagogical Explanations (Skin)
- llm_explanations = [
- {"step_id": 1, "pedagogical_explanation": "נתונה לנו פונקציה ריבועית פשוטה."},
- {"step_id": 2, "pedagogical_explanation": "נשתמש בכלל החזקה כדי למצוא את הנגזרת."}
- ]
-
- try:
- # 3. Perform the Merge
- final_nodes = merge_and_verify_explanations(sympy_nodes, llm_explanations)
-
- print(" ✅ Merge Success!")
- assert len(final_nodes) == 2
- assert final_nodes[1]["math"] == "f'(x) = 2x"
- assert final_nodes[1]["pedagogical_explanation"] == "נשתמש בכלל החזקה כדי למצוא את הנגזרת."
-
- except LLMSchemaError as e:
- print(f" ❌ Merge Failed: {e}")
- assert False, "Golden Merge should have succeeded"
-
- # 4. Test Schema Violations
- print("\n🛡️ Testing Schema Enforcement (Swiss Watch Locks):")
-
- # Violation A: Length Mismatch
- try:
- merge_and_verify_explanations(sympy_nodes, llm_explanations[:1])
- assert False, "Should have failed on length mismatch"
- except LLMSchemaError:
- print(" ✅ Length Mismatch Guard - PASSED")
-
- # Violation B: Unexpected Keys (No Math allowed from LLM)
- llm_with_math = [
- {"step_id": 1, "pedagogical_explanation": "...", "math": "hacked math"},
- {"step_id": 2, "pedagogical_explanation": "..."}
- ]
- try:
- merge_and_verify_explanations(sympy_nodes, llm_with_math)
- assert False, "Should have failed on unexpected keys (math)"
- except LLMSchemaError:
- print(" ✅ Key Enforcement Guard - PASSED")
-
- # Violation C: Step Order Modified
- llm_shuffled = [
- {"step_id": 2, "pedagogical_explanation": "Step 2"},
- {"step_id": 1, "pedagogical_explanation": "Step 1"}
- ]
- try:
- merge_and_verify_explanations(sympy_nodes, llm_shuffled)
- assert False, "Should have failed on step order"
- except LLMSchemaError:
- print(" ✅ Step Order Guard - PASSED")
-
- print("\n🏆 The Golden Test: V2.5.3 Swiss Watch - VERIFIED!")
-
-if __name__ == "__main__":
- test_golden_merge()
diff --git a/tests/verify_v111_hotfix.py b/tests/verify_v111_hotfix.py
deleted file mode 100644
index f145695aee37b2231303eb019760214b30d4eee4..0000000000000000000000000000000000000000
--- a/tests/verify_v111_hotfix.py
+++ /dev/null
@@ -1,33 +0,0 @@
-import json
-from math_sanitizer import sanitize_math_ocr_hotfix
-from orchestrator import select_best_anchor
-
-def verify_v111_hotfix():
- print("🚀 Starting BuddyMath V1.1.1 Hotfix Verification")
-
- # 1. Test Aggressive Sanitization (CTO Requirement)
- print("\n🧼 Testing Aggressive Sanitization:")
- test_cases = [
- (" frac(x)(2)", "((x)/(2))"),
- ("frac (x) ( 2 )", "((x)/(2))"),
- ("\\left( x + 1 \\right)", "(x+1)")
- ]
-
- for input_str, expected in test_cases:
- result = sanitize_math_ocr_hotfix(input_str)
- print(f" - '{input_str}' -> '{result}'")
- assert result == expected or result == expected.replace(" ", ""), f"Sanitization mismatch: {result} != {expected}"
- print(" ✅ Aggressive Sanitization - PASSED")
-
- # 2. Test Smart Anchor Selection
- print("\n⚓ Testing Smart Anchor Selection:")
- candidates = ["f(x)=x", "f(x)=x^2+1", "f(x)=x^2"]
- best = select_best_anchor(candidates)
- print(f" - Best of {candidates} -> '{best}'")
- assert best == "f(x)=x^2+1", "Smart anchor selection failed (longest policy)"
- print(" ✅ Smart Anchor Selection - PASSED")
-
- print("\n🏆 BuddyMath V1.1.1 Hotfix Verified!")
-
-if __name__ == "__main__":
- verify_v111_hotfix()
diff --git a/tests/verify_v313_hotfix.py b/tests/verify_v313_hotfix.py
deleted file mode 100644
index 7d1e7011f0346f36bcdd3eabb681fc12a15a8a49..0000000000000000000000000000000000000000
--- a/tests/verify_v313_hotfix.py
+++ /dev/null
@@ -1,30 +0,0 @@
-from quota_system import QuotaManager
-from config import IS_PRODUCTION
-
-def verify_v313_hotfix():
- print(f"🔍 [VERIFY] IS_PRODUCTION: {IS_PRODUCTION}")
-
- qm = QuotaManager()
- # Check default limit
- limit = qm.limits_config.get("default_limit")
- print(f"📊 [VERIFY] Default Quota: {limit}")
-
- if not IS_PRODUCTION:
- if limit == 1000:
- print("✅ [VERIFY] DEV Quota 1000 - PASSED")
- else:
- print(f"❌ [VERIFY] DEV Quota 1000 - FAILED (Got {limit})")
- else:
- print("ℹ️ [VERIFY] Production environment, skipping 1000-quota check.")
-
- from config import CONFIDENCE_THRESHOLD_HIGH, CONFIDENCE_THRESHOLD_MEDIUM
- print(f"⚖️ [VERIFY] High Threshold: {CONFIDENCE_THRESHOLD_HIGH}")
- print(f"⚖️ [VERIFY] Medium Threshold: {CONFIDENCE_THRESHOLD_MEDIUM}")
-
- if CONFIDENCE_THRESHOLD_HIGH == 0.75 and CONFIDENCE_THRESHOLD_MEDIUM == 0.55:
- print("✅ [VERIFY] Thresholds (0.75/0.55) - PASSED")
- else:
- print("❌ [VERIFY] Thresholds - FAILED")
-
-if __name__ == "__main__":
- verify_v313_hotfix()
diff --git a/tests/verify_v47_rollback.py b/tests/verify_v47_rollback.py
deleted file mode 100644
index 788e923763da9d06f7bfb916b3c8d14a5bc6be77..0000000000000000000000000000000000000000
--- a/tests/verify_v47_rollback.py
+++ /dev/null
@@ -1,74 +0,0 @@
-import json
-import re
-import logging
-import asyncio
-import sympy as sp
-from strategy_manager import StrategyManager
-from pedagogical_builder import build_pedagogical_response
-from orchestrator import verify_math_consistency
-
-# Configure logging
-logging.basicConfig(level=logging.INFO)
-logger = logging.getLogger(__name__)
-
-async def test_v47_rollback():
- print("🚀 Starting V4.7 Rollback & SymPy Hardening Verification")
-
- # 1. Test SymPy Hardening (Risk: Equations crashing)
- print("\n⚖️ Testing SymPy Hardening:")
-
- # Test 1: Equation vs Value (Should be False for identity, even if consistent)
- anchor = "x^2 = 4"
- result = "2"
- is_id, scale = verify_math_consistency(anchor, result)
- print(f" - Equation 'x^2 = 4' vs '2': Identical={is_id}, Scale={scale}")
- assert is_id == False, "Equation and value should not be identical"
- assert scale == 0.6, "Scale should be 0.6 for non-identity"
-
- # Test 1b: True Identity (Expression vs Expression)
- is_id, scale = verify_math_consistency("x^2 + 1", "1 + x^2")
- print(f" - Identity 'x^2 + 1' vs '1 + x^2': Identical={is_id}, Scale={scale}")
- assert is_id == True, "Symbolic identity failed"
- assert scale == 1.0, "Scale should be 1.0 for identity"
-
- # Test 2: Invalid syntax handling
- is_id, scale = verify_math_consistency("!!!invalid!!!", "2")
- print(f" - Invalid syntax handling: Identical={is_id}, Scale={scale}")
- assert is_id == False, "SymPy should return False on crash"
-
- # Test 3: Derivative Normalization (Hotfix #1)
- # Using explicit multiplication (*) as sympify is strict
- is_id, scale = verify_math_consistency("f'(x) = 2*x", "Derivative(f(x), x) = 2*x")
- print(f" - Derivative 'f'(x)' normalization: Identical={is_id}, Scale={scale}")
- assert is_id == True, "Derivative normalization identity failed"
-
- # 2. Test Strategy Manager V4.7 Output
- sm = StrategyManager(None)
- raw_response = """
- {
- "confidence_score": 0.9,
- "is_valid": true,
- "latex_result": "x=2",
- "solution_markdown": "התשובה היא $x=2$."
- }
- """
- parsed = sm._parse_v4_json(raw_response)
- print("\n✅ Strategy Manager V4.7 Parsing: Passed")
-
- # 3. Test Pedagogical Builder Priority Bypass (Hotfix #2)
- ped_response = build_pedagogical_response(
- topic_id="GENERAL", # Template should be bypassed
- llm_output=parsed,
- data_anchor={}
- )
- steps = ped_response['sections'][0]['steps']
- print(f"\n📊 Pedagogical steps count (Priority Bypass): {len(steps)}")
- # Bypass logic returns precisely 1 step in 1 section
- assert len(steps) == 1, f"Priority bypass failed, got {len(steps)} steps"
- assert "התשובה היא" in steps[0]['content_mixed'], "Solution content mismatch"
- print("✅ Pedagogical Priority Bypass - PASSED")
-
- print("\n🏆 V4.7 Rollback & SymPy Hardening Verified!")
-
-if __name__ == "__main__":
- asyncio.run(test_v47_rollback())
diff --git a/tests/verify_v5_pipeline.py b/tests/verify_v5_pipeline.py
deleted file mode 100644
index 3e15bade9c6ef8db778290d75aa3e99a313e666d..0000000000000000000000000000000000000000
--- a/tests/verify_v5_pipeline.py
+++ /dev/null
@@ -1,74 +0,0 @@
-import json
-import re
-import logging
-import asyncio
-from strategy_manager import StrategyManager
-from pedagogical_builder import build_pedagogical_response
-from orchestrator import verify_math_consistency
-
-# Configure logging
-logging.basicConfig(level=logging.INFO)
-logger = logging.getLogger(__name__)
-
-async def test_v5_pipeline():
- print("🚀 Starting V5.0 Pipeline Verification Test")
-
- # 1. Mock Raw LLM Output (with Prefix Leakage Risk #1)
- raw_response = """
- Here is the structured solution for you:
- {
- "confidence_score": 0.95,
- "uncertainty_flag": false,
- "is_valid": true,
- "blocks": [
- {"type": "text", "content": "בוא נפתור את המשוואה הנפלאה הזו!"},
- {"type": "math", "content": "x^2 - 4 = 0"},
- {"type": "text", "content": "נעביר אגף:"},
- {"type": "math", "content": "x^2 = 4"},
- {"type": "text", "content": "ונוציא שורש:"},
- {"type": "math", "content": "x = \\\\pm 2"}
- ]
- }
- """
-
- # 2. Test Strategy Manager Parsing & Hardening
- sm = StrategyManager(None) # Model not needed for parsing test
- parsed = sm._parse_v5_json(raw_response)
-
- print("✅ Step 1: Prefix Leakage Protection - PASSED")
- print(f"📊 Parsed block count: {len(parsed.get('blocks', []))}")
-
- assert len(parsed['blocks']) == 6, "Block count mismatch"
- assert sm._validate_v5_schema(parsed), "Schema validation failed"
- print("✅ Step 2: Schema Validation - PASSED")
-
- # 3. Test Pedagogical Builder Mapping
- response = build_pedagogical_response(
- topic_id="GENERAL",
- llm_output=parsed,
- data_anchor={}
- )
-
- steps = response['sections'][0]['steps']
- print(f"📊 Pedagogical steps count: {len(steps)}")
- # Template 'GENERAL' adds: Intro step + 'Approach' step + 6 blocks + 'Solution' step = 9 steps
- assert len(steps) == 9, f"Pedagogical mapping failed, expected 9 steps, got {len(steps)}"
- print("✅ Step 3: Pedagogical Mapping - PASSED")
-
- # 4. Test Orchestrator Anchor Protection (Risk #3)
- # Simulate orchestrator extraction
- blocks = parsed.get("blocks", [])
- math_blocks = [b for b in blocks if b.get("type") == "math"]
- llm_fn = math_blocks[-1]["content"] if math_blocks else "None"
-
- print(f"⚖️ Extracted final result for verification: {llm_fn}")
- assert llm_fn == "x = \\pm 2", "Anchor protection extraction failed"
-
- anchor_fn = "x^2 = 4" # Simple anchor for test
- is_identical, scale = verify_math_consistency(anchor_fn, "2")
- print(f"⚖️ Symbolic verification (x^2=4 vs 2): {is_identical}, scale: {scale}")
-
- print("\n🏆 V5.0 End-to-End Pipeline Verified!")
-
-if __name__ == "__main__":
- asyncio.run(test_v5_pipeline())
diff --git a/tests/verify_validation.py b/tests/verify_validation.py
deleted file mode 100644
index d186e10f7aecd1c219dadc89a2006fa2c6cfcec0..0000000000000000000000000000000000000000
--- a/tests/verify_validation.py
+++ /dev/null
@@ -1,27 +0,0 @@
-import asyncio
-import json
-from sympy import symbols, Eq
-from strategy_manager import StrategyManager
-
-async def run_test():
- sm = StrategyManager()
-
- print("--- TEST 1: General Prompt with Good Math ---")
- topic_id = "CIRCLE_EQUATION"
- data_anchor = {"center": [0,0], "radius": 5}
- llm_resp = {"solution": "x^2 + y^2 = 25"}
-
- res = sm._validate(topic_id, llm_resp, data_anchor)
- print(f"Validation Result: {res}")
-
- print("\n--- TEST 2: General Prompt with Bad Math ---")
- llm_resp_bad = {"solution": "x^2 - (y^2 / 3) = 1"}
- res2 = sm._validate(topic_id, llm_resp_bad, data_anchor)
- print(f"Validation Result: {res2}")
-
- print("\n--- TEST 3: General Prompt on Unknown Topic ---")
- res3 = sm._validate("WEIRD_TOPIC", {}, {})
- print(f"Validation Result: {res3}")
-
-if __name__ == "__main__":
- asyncio.run(run_test())
diff --git a/topic_taxonomy.py b/topic_taxonomy.py
deleted file mode 100644
index 9d5cb43db83ac28dd890b34c0a31fd486af2df12..0000000000000000000000000000000000000000
--- a/topic_taxonomy.py
+++ /dev/null
@@ -1,611 +0,0 @@
-# topic_taxonomy.py - V231.12
-# Topic detection and classification for BuddyMath
-# Based on Israeli curriculum grades 7-12
-
-"""
-Topic Taxonomy for BuddyMath - Comprehensive Coverage
-Follows BUDDYMATH_COMPLETE_GUIDE principles:
-- General patterns, not example-specific
-- Keyword-based detection
-- Grade-appropriate classification
-"""
-
-# ==================== TOPIC TAXONOMY ====================
-
-TOPIC_TAXONOMY = {
- # ========== GEOMETRY (Grades 7-12) ==========
-
- # V275.3: GEOMETRIC_LOCUS must come BEFORE CIRCLE_EQUATION!
- # Locus problems mention "מעגל" but are NOT circle-equation problems.
- # Without a micro-prompt, this falls back to the flexible general prompt.
- "GEOMETRIC_LOCUS": {
- "keywords": ["גיאומטרי"],
- "grade_range": (10, 12),
- "category": "GEOMETRY",
- "complexity": "high"
- },
-
- "CIRCLE_EQUATION": {
- "keywords": ["מעגל", "מרכז", "רדיוס"],
- "exclude_if": ["גיאומטרי"], # V275.3: Don't match locus problems
- "grade_range": (9, 12),
- "category": "GEOMETRY",
- "complexity": "medium"
- },
-
- "CIRCLE_TANGENT": {
- "keywords": ["מעגל", "משיק", "נקודת משיק"],
- "grade_range": (10, 12),
- "category": "GEOMETRY",
- "complexity": "high"
- },
-
- "TRIANGLE_AREA": {
- "keywords": ["משולש", "שטח"],
- "grade_range": (7, 10),
- "category": "GEOMETRY",
- "complexity": "low"
- },
-
- "TRIANGLE_PYTHAGOREAN": {
- "keywords": ["משולש", "פיתגורס", "ישר זווית"],
- "grade_range": (8, 10),
- "category": "GEOMETRY",
- "complexity": "medium"
- },
-
- "LINE_EQUATION": {
- "keywords": ["ישר", "משוואה", "שיפוע"],
- "grade_range": (9, 12),
- "category": "GEOMETRY",
- "complexity": "low"
- },
-
- "LINE_PERPENDICULAR": {
- "keywords": ["ישר", "אנך", "ניצב"],
- "grade_range": (9, 12),
- "category": "GEOMETRY",
- "complexity": "medium"
- },
-
- "LINE_PARALLEL": {
- "keywords": ["ישר", "מקביל"],
- "grade_range": (9, 12),
- "category": "GEOMETRY",
- "complexity": "low"
- },
-
- "DISTANCE_FORMULA": {
- "keywords": ["מרחק", "נקודות"],
- "grade_range": (9, 12),
- "category": "GEOMETRY",
- "complexity": "low"
- },
-
- "PARABOLA": {
- "keywords": ["פרבולה", "ריבועית"],
- "grade_range": (10, 12),
- "category": "GEOMETRY",
- "complexity": "high"
- },
-
- # ========== CALCULUS (Grades 11-12) ==========
-
- "DERIVATIVE_POWER": {
- "keywords": ["נגזרת", "חזקה"],
- "grade_range": (11, 12),
- "category": "CALCULUS",
- "complexity": "low"
- },
-
- "DERIVATIVE_QUOTIENT": {
- "keywords": ["נגזרת", "מנה", "חילוק"],
- "grade_range": (11, 12),
- "category": "CALCULUS",
- "complexity": "high"
- },
-
- "DERIVATIVE_PRODUCT": {
- "keywords": ["נגזרת", "מכפלה"],
- "grade_range": (11, 12),
- "category": "CALCULUS",
- "complexity": "medium"
- },
-
- "DERIVATIVE_CHAIN": {
- "keywords": ["נגזרת", "שרשרת", "הרכבה"],
- "grade_range": (11, 12),
- "category": "CALCULUS",
- "complexity": "high"
- },
-
- "DERIVATIVE_TRIG": {
- "keywords": ["נגזרת", "sin", "cos", "tan"],
- "grade_range": (11, 12),
- "category": "CALCULUS",
- "complexity": "medium"
- },
-
- "INVESTIGATION_EXTREMA": {
- "keywords": ["חקור", "קיצון", "מקסימום", "מינימום"],
- "grade_range": (11, 12),
- "category": "INVESTIGATION",
- "complexity": "high"
- },
-
- "INVESTIGATION_MONOTONICITY": {
- "keywords": ["חקור", "עולה", "יורדת"],
- "grade_range": (11, 12),
- "category": "INVESTIGATION",
- "complexity": "medium"
- },
-
- "INVESTIGATION_CONCAVITY": {
- "keywords": ["חקור", "קמירות", "קעירות"],
- "grade_range": (12, 12),
- "category": "INVESTIGATION",
- "complexity": "high"
- },
-
- "INVESTIGATION_ASYMPTOTES": {
- "keywords": ["חקור", "אסימפטוטה"],
- "grade_range": (11, 12),
- "category": "INVESTIGATION",
- "complexity": "high"
- },
-
- "INTEGRAL_BASIC": {
- "keywords": ["אינטגרל", "שטח"],
- "grade_range": (12, 12),
- "category": "CALCULUS",
- "complexity": "medium"
- },
-
- # ========== ALGEBRA (Grades 7-12) ==========
-
- "LINEAR_EQUATION": {
- "keywords": ["משוואה", "x=", "פתור"],
- "grade_range": (7, 9),
- "category": "ALGEBRA",
- "complexity": "low"
- },
-
- "QUADRATIC_EQUATION": {
- "keywords": ["משוואה", "ריבועית", "x²"],
- "grade_range": (9, 12),
- "category": "ALGEBRA",
- "complexity": "medium"
- },
-
- "SYSTEM_EQUATIONS": {
- "keywords": ["מערכת", "משוואות"],
- "grade_range": (8, 10),
- "category": "ALGEBRA",
- "complexity": "medium"
- },
-
- "EXPONENTS": {
- "keywords": ["חזקה", "מעריכי"],
- "grade_range": (7, 10),
- "category": "ALGEBRA",
- "complexity": "low"
- },
-
- "LOGARITHM": {
- "keywords": ["לוגריתם", "log"],
- "grade_range": (11, 12),
- "category": "ALGEBRA",
- "complexity": "high"
- },
-
- # ========== TRIGONOMETRY (Grades 10-12) ==========
-
- "TRIG_BASIC": {
- "keywords": ["sin", "cos", "tan", "סינוס", "קוסינוס"],
- "grade_range": (10, 12),
- "category": "TRIGONOMETRY",
- "complexity": "medium"
- },
-
- "TRIG_IDENTITIES": {
- "keywords": ["זהות", "טריגונומטרית"],
- "grade_range": (11, 12),
- "category": "TRIGONOMETRY",
- "complexity": "high"
- },
-
- # ========== PROBABILITY (Grades 11-12) ==========
-
- "PROBABILITY_BASIC": {
- "keywords": ["הסתברות", "סיכוי"],
- "grade_range": (11, 12),
- "category": "PROBABILITY",
- "complexity": "medium"
- },
-
- "COMBINATORICS": {
- "keywords": ["קומבינטוריקה", "צירופים", "תמורות"],
- "grade_range": (11, 12),
- "category": "PROBABILITY",
- "complexity": "high"
- },
-
- "PROBABILITY_CONDITIONAL": {
- "keywords": ["הסתברות", "מותנית", "P(", "בהינתן"],
- "grade_range": (11, 12),
- "category": "PROBABILITY",
- "complexity": "high"
- },
-
- "PROBABILITY_TREE": {
- "keywords": ["הסתברות", "עץ", "שלבים", "מדגם"],
- "grade_range": (9, 12),
- "category": "PROBABILITY",
- "complexity": "medium"
- },
-
- # ========== SEQUENCES (Grades 9-12) ==========
-
- "SEQUENCE_ARITHMETIC": {
- "keywords": ["סדרה", "חשבונית", "הפרש"],
- "grade_range": (9, 12),
- "category": "SEQUENCES",
- "complexity": "medium"
- },
-
- "SEQUENCE_GEOMETRIC": {
- "keywords": ["סדרה", "הנדסית", "מנה"],
- "grade_range": (9, 12),
- "category": "SEQUENCES",
- "complexity": "medium"
- },
-
- "SEQUENCE_SUM": {
- "keywords": ["סכום", "סדרה", "חלקי", "ראשונים"],
- "grade_range": (10, 12),
- "category": "SEQUENCES",
- "complexity": "high"
- },
-
- "SEQUENCE_RECURSIVE": {
- "keywords": ["סדרה", "נוסחת", "נסיגה", "רקורסיבית"],
- "grade_range": (10, 12),
- "category": "SEQUENCES",
- "complexity": "high"
- },
-
- # ========== COMPLEX NUMBERS (Grades 11-12, 5 units) ==========
-
- "COMPLEX_BASIC": {
- "keywords": ["מרוכב", "מדומה", "i"],
- "grade_range": (11, 12),
- "category": "COMPLEX_NUMBERS",
- "complexity": "medium"
- },
-
- "COMPLEX_POLAR": {
- "keywords": ["מרוכב", "קוטבי", "טריגונומטרי", "מודולוס"],
- "grade_range": (11, 12),
- "category": "COMPLEX_NUMBERS",
- "complexity": "high"
- },
-
- "COMPLEX_ROOTS": {
- "keywords": ["מרוכב", "שורש", "דה-מואבר"],
- "grade_range": (11, 12),
- "category": "COMPLEX_NUMBERS",
- "complexity": "high"
- },
-
- # ========== VECTORS (Grade 12, 5 units) ==========
-
- "VECTORS_BASIC": {
- "keywords": ["וקטור", "גודל", "כיוון"],
- "grade_range": (12, 12),
- "category": "VECTORS",
- "complexity": "medium"
- },
-
- "VECTORS_OPERATIONS": {
- "keywords": ["וקטור", "מכפלה", "סקלרית", "חיבור"],
- "grade_range": (12, 12),
- "category": "VECTORS",
- "complexity": "high"
- },
-
- # ========== INDUCTION (Grades 11-12, 5 units) ==========
-
- "INDUCTION": {
- "keywords": ["אינדוקציה", "הוכח", "n=k", "n טבעי"],
- "grade_range": (11, 12),
- "category": "PROOFS",
- "complexity": "high"
- },
-
- # ========== STATISTICS (Grades 7-12) ==========
-
- "STATISTICS_DESCRIPTIVE": {
- "keywords": ["ממוצע", "חציון", "שכיח", "סטיית", "תקן"],
- "grade_range": (7, 12),
- "category": "STATISTICS",
- "complexity": "medium"
- },
-
- "STATISTICS_DISTRIBUTION": {
- "keywords": ["התפלגות", "נורמלית", "בינומית"],
- "grade_range": (11, 12),
- "category": "STATISTICS",
- "complexity": "high"
- },
-
- # ========== CALCULUS EXTENSIONS (Grades 11-12) ==========
-
- "INTEGRAL_DEFINITE": {
- "keywords": ["אינטגרל", "מסוים", "גבולות"],
- "grade_range": (12, 12),
- "category": "CALCULUS",
- "complexity": "high"
- },
-
- "INTEGRAL_AREA": {
- "keywords": ["שטח", "אינטגרל", "תחום", "בין"],
- "grade_range": (12, 12),
- "category": "CALCULUS",
- "complexity": "high"
- },
-
- "DIFFERENTIAL_EQUATION": {
- "keywords": ["דיפרנציאלית", "y'", "dy"],
- "grade_range": (12, 12),
- "category": "CALCULUS",
- "complexity": "high"
- },
-
- "INVESTIGATION_FULL": {
- "keywords": ["חקור", "פונקציה", "תחום", "הגדרה"],
- "grade_range": (11, 12),
- "category": "INVESTIGATION",
- "complexity": "high"
- },
-
- # ========== TRIGONOMETRY EXTENSIONS (Grades 10-12) ==========
-
- "TRIG_EQUATIONS": {
- "keywords": ["משוואה", "טריגונומטרית", "sin", "cos"],
- "grade_range": (10, 12),
- "category": "TRIGONOMETRY",
- "complexity": "high"
- },
-
- "TRIG_LAW_SINES": {
- "keywords": ["סינוסים", "משפט"],
- "grade_range": (10, 12),
- "category": "TRIGONOMETRY",
- "complexity": "medium"
- },
-
- "TRIG_LAW_COSINES": {
- "keywords": ["קוסינוסים", "משפט"],
- "grade_range": (10, 12),
- "category": "TRIGONOMETRY",
- "complexity": "medium"
- },
-
- # ========== GEOMETRY EXTENSIONS (Grades 7-10) ==========
-
- "TRIANGLE_CONGRUENCE": {
- "keywords": ["חפיפה", "משולש"],
- "grade_range": (8, 10),
- "category": "GEOMETRY",
- "complexity": "medium"
- },
-
- "TRIANGLE_SIMILARITY": {
- "keywords": ["דמיון", "משולש"],
- "grade_range": (9, 12),
- "category": "GEOMETRY",
- "complexity": "medium"
- },
-
- "THALES_THEOREM": {
- "keywords": ["תאלס", "משפט"],
- "grade_range": (9, 12),
- "category": "GEOMETRY",
- "complexity": "medium"
- },
-
- "CIRCLE_GEOMETRY": {
- "keywords": ["מעגל", "זווית", "היקפית", "מרכזית", "מיתר"],
- "exclude_if": ["משוואה", "רדיוס"],
- "grade_range": (9, 12),
- "category": "GEOMETRY",
- "complexity": "medium"
- },
-
- "VOLUME_SURFACE_AREA": {
- "keywords": ["נפח", "שטח פנים", "גליל", "חרוט", "כדור", "תיבה", "פירמידה"],
- "grade_range": (7, 10),
- "category": "GEOMETRY",
- "complexity": "medium"
- },
-
- "QUADRILATERALS": {
- "keywords": ["מרובע", "מקבילית", "טרפז", "מעוין", "מלבן"],
- "grade_range": (7, 10),
- "category": "GEOMETRY",
- "complexity": "low"
- },
-
- # ========== ALGEBRA EXTENSIONS (Grades 7-12) ==========
-
- "INEQUALITY": {
- "keywords": ["אי-שוויון", "אי שוויון", "גדול", "קטן"],
- "grade_range": (7, 12),
- "category": "ALGEBRA",
- "complexity": "medium"
- },
-
- "ABSOLUTE_VALUE": {
- "keywords": ["ערך מוחלט", "|x|"],
- "grade_range": (9, 12),
- "category": "ALGEBRA",
- "complexity": "medium"
- },
-
- "PERCENTAGE": {
- "keywords": ["אחוז", "אחוזים", "%"],
- "grade_range": (7, 10),
- "category": "ALGEBRA",
- "complexity": "low"
- },
-
- "RATIO_PROPORTION": {
- "keywords": ["יחס", "פרופורציה", "ישר", "הפוך"],
- "grade_range": (7, 9),
- "category": "ALGEBRA",
- "complexity": "low"
- },
-
- "ALGEBRAIC_EXPRESSIONS": {
- "keywords": ["ביטוי", "אלגברי", "פשט", "צמצם"],
- "grade_range": (7, 10),
- "category": "ALGEBRA",
- "complexity": "low"
- },
-
- "WORD_PROBLEM": {
- "keywords": ["בעיית", "מילולית", "תרגום", "משוואה"],
- "grade_range": (7, 12),
- "category": "ALGEBRA",
- "complexity": "medium"
- },
-
- # ========== FUNCTIONS (Grades 9-12) ==========
-
- "FUNCTION_SQRT": {
- "keywords": ["פונקציית", "שורש", "√"],
- "grade_range": (10, 12),
- "category": "ALGEBRA",
- "complexity": "medium"
- },
-
- "FUNCTION_EXPONENTIAL": {
- "keywords": ["מעריכית", "e^", "אקספוננציאלית"],
- "grade_range": (11, 12),
- "category": "ALGEBRA",
- "complexity": "medium"
- },
-
- "FUNCTION_DOMAIN": {
- "keywords": ["תחום", "הגדרה", "פונקציה"],
- "grade_range": (9, 12),
- "category": "ALGEBRA",
- "complexity": "medium"
- },
-
- "GRAPH_IDENTIFICATION": {
- "keywords": ["איזה מהגרפים", "מתאים לפונקציה", "התאם בין", "מתאמת לגרף"],
- "grade_range": (9, 12),
- "category": "GRAPH_IDENTIFICATION",
- "complexity": "high"
- },
-}
-
-# ==================== DETECTION FUNCTIONS ====================
-
-def detect_topic(text: str, grade: str = "10") -> str:
- """
- Detect specific mathematical topic from problem text.
-
- Args:
- text: Problem text (OCR output)
- grade: Student grade (e.g., "10", "י\"א 5 יח\"ל")
-
- Returns:
- Topic ID (e.g., "CIRCLE_EQUATION") or "GENERAL"
-
- Strategy:
- 1. Extract grade number
- 2. Check each topic's keywords
- 3. Filter by grade range
- 4. Return most specific match
- """
- text_lower = text.lower()
-
- # Extract grade number
- grade_num = _extract_grade_number(grade)
-
- # Find matching topics
- matches = []
- for topic_id, config in TOPIC_TAXONOMY.items():
- # Check grade range
- min_grade, max_grade = config["grade_range"]
- if not (min_grade <= grade_num <= max_grade):
- continue
-
- # V275.3: Check exclusion keywords
- exclude_keywords = config.get("exclude_if", [])
- if any(ek in text_lower for ek in exclude_keywords):
- continue
-
- # Check keywords (need at least 2 matches for specificity)
- keywords = config["keywords"]
- matches_count = sum(1 for kw in keywords if kw in text_lower)
-
- if matches_count >= min(2, len(keywords)):
- matches.append((topic_id, matches_count, config["complexity"]))
-
- if not matches:
- return "GENERAL"
-
- # Sort by: 1) keyword matches, 2) complexity (prefer specific)
- matches.sort(key=lambda x: (x[1], x[2] == "high"), reverse=True)
-
- return matches[0][0]
-
-
-def get_topic_category(topic_id: str) -> str:
- """Get category for a topic (GEOMETRY, CALCULUS, etc.)"""
- if topic_id == "GENERAL":
- return "GENERAL"
- return TOPIC_TAXONOMY.get(topic_id, {}).get("category", "GENERAL")
-
-
-def _extract_grade_number(grade: str) -> int:
- """Extract numeric grade from string like 'י\"א 5 יח\"ל' or '10'"""
- # Hebrew grades
- hebrew_grades = {
- 'ז': 7, 'ח': 8, 'ט': 9,
- 'י': 10, 'יא': 11, 'יב': 12
- }
-
- for heb, num in hebrew_grades.items():
- if heb in grade:
- return num
-
- # Numeric grades
- import re
- match = re.search(r'\d+', grade)
- if match:
- return int(match.group())
-
- return 10 # Default
-
-
-# ==================== USAGE EXAMPLE ====================
-
-if __name__ == "__main__":
- # Test cases
- test_cases = [
- ("מעגל עם מרכז M(3,5) ורדיוס 5", "10"),
- ("חקור את הפונקציה f(x) = x³ - 3x", "י\"א 5 יח\"ל"),
- ("פתור את המשוואה x² + 5x + 6 = 0", "9"),
- ("מצא את הנגזרת של f(x) = sin(x²)", "12"),
- ]
-
- for text, grade in test_cases:
- topic = detect_topic(text, grade)
- category = get_topic_category(topic)
- print(f"Text: {text[:40]}...")
- print(f"Grade: {grade} → Topic: {topic} ({category})")
- print()
diff --git a/tts_manager.py b/tts_manager.py
deleted file mode 100644
index 5c78178389796bdc8d4380fbb3d54c1c1806babf..0000000000000000000000000000000000000000
--- a/tts_manager.py
+++ /dev/null
@@ -1,72 +0,0 @@
-import os
-import re
-import time
-import torch
-import torchaudio
-import warnings
-from pydub import AudioSegment
-from firebase_manager import firebase_manager
-
-warnings.filterwarnings("ignore")
-
-# פתרון גנרי לטעינה על CPU
-original_load = torch.load
-torch.load = lambda *args, **kwargs: original_load(*args, map_location='cpu', **kwargs)
-
-from chatterbox.mtl_tts import ChatterboxMultilingualTTS
-from phonikud_onnx import Phonikud
-from phonikud import lexicon
-
-class TTSManager:
- def __init__(self, reference_audio_path="teacher_reference.wav"):
- print("⚙️ טוען מנוע 'המורה למתמטיקה' (Production Mode)...")
- self.device = "cpu"
- self.ref_audio_path = reference_audio_path
- self.phonikud_model = Phonikud("phonikud-1.0.int8.onnx")
- self.tts_model = ChatterboxMultilingualTTS.from_pretrained(device=self.device)
- print("✅ המנוע מוכן לייצור דינמי.")
-
- def _split_to_sentences(self, text):
- return [s.strip() for s in re.split(r'(?<=[.!?]) +', text) if s.strip()]
-
- def generate_and_upload(self, text):
- """
- מייצר קול חדש, מעלה לענן ומחזיר URL.
- משמש רק למשפטים דינמיים שאינם קבועים מראש.
- """
- timestamp = int(time.time())
- local_temp = f"audio_{timestamp}.wav"
-
- sentences = self._split_to_sentences(text)
- combined = AudioSegment.empty()
- silence = AudioSegment.silent(duration=800)
-
- for i, sentence in enumerate(sentences):
- temp_part = f"temp_{timestamp}_{i}.wav"
- voweled = self.phonikud_model.add_diacritics(sentence)
- voweled = re.sub(fr"[{lexicon.NON_STANDARD_DIAC}]", "", voweled)
-
- wav = self.tts_model.generate(
- voweled,
- language_id="he",
- audio_prompt_path=self.ref_audio_path,
- cfg_weight=0.90
- )
- torchaudio.save(temp_part, wav, self.tts_model.sr)
- combined += AudioSegment.from_wav(temp_part) + silence
- os.remove(temp_part)
-
- combined.export(local_temp, format="wav")
-
- # העלאה לפי הפורמט שביקשת
- url = firebase_manager.upload_file(local_temp, f"audio/{local_temp}")
-
- # ניקוי מקומי
- if os.path.exists(local_temp):
- os.remove(local_temp)
-
- return url
-
-# בסיום הקוד לא מריצים כלום - הוא מחכה לייבוא (Import) מהשרת
-if __name__ == "__main__":
- print("🚀 TTS Manager מוכן לשימוש. אין הרצות אוטומיות.")
\ No newline at end of file
diff --git a/tutor_reminder_engine.py b/tutor_reminder_engine.py
deleted file mode 100644
index 2ff889a1c11aa5115be5b251930fe8e37f91085c..0000000000000000000000000000000000000000
--- a/tutor_reminder_engine.py
+++ /dev/null
@@ -1,94 +0,0 @@
-# buddy_math_server/tutor_reminder_engine.py
-import time
-import logging
-from firebase_manager import firebase_manager
-from firebase_admin import messaging
-
-logging.basicConfig(level=logging.INFO)
-logger = logging.getLogger(__name__)
-
-def send_push_notification(uid, name, category):
- """
- Sends a personalized FCM notification to the user.
- """
- try:
- # 1. Fetch user FCM token (In a real app, this would be in the user doc)
- db = firebase_manager.get_db()
- user_doc = db.collection('users').document(uid).get()
- if not user_doc.exists:
- logger.warning(f"⚠️ [REMINDER] User {uid} not found.")
- return False
-
- fcm_token = user_doc.to_dict().get('fcm_token')
- if not fcm_token:
- logger.warning(f"⚠️ [REMINDER] No FCM token for user {uid}.")
- return False
-
- # 2. Construct Message
- message_body = f"היי {name}, המורה מחכה לראות איך פתרת את התרגיל ב-{category}! זה ייקח רק 2 דקות. בא לך לנסות? 💪"
-
- message = messaging.Message(
- notification=messaging.Notification(
- title="האתגר היומי של Buddy-Math ✏️",
- body=message_body
- ),
- token=fcm_token
- )
-
- # 3. Send
- response = messaging.send(message)
- logger.info(f"🚀 [REMINDER] Push sent to {uid}: {response}")
- return True
- except Exception as e:
- logger.error(f"❌ [REMINDER] FCM Error: {e}")
- return False
-
-def run_reminder_check():
- """
- V318.0: The Nudge Logic.
- Finds pending suggestions older than 2 hours and sends reminders.
- """
- try:
- db = firebase_manager.get_db()
- if not db: return
-
- # Query all users (In production, use a more efficient index if possible)
- # For this phase, we iterate through suggestions that match criteria
- two_hours_ago = time.time() - (2 * 60 * 60)
-
- # We need to query across all users' suggestions.
- # Firestore collectionGroup is ideal here.
- suggestions = db.collection_group('suggestions') \
- .where('status', '==', 'pending') \
- .where('reminder_sent', '==', False) \
- .where('created_at', '<', two_hours_ago) \
- .stream()
-
- count = 0
- for sug in suggestions:
- data = sug.to_dict()
- sug_path = sug.reference.path # users/{uid}/suggestions/{sid}
- uid = sug_path.split('/')[1]
-
- # Get user name
- user_doc = db.collection('users').document(uid).get()
- name = user_doc.to_dict().get('name', 'חבר')
- category = data.get('category', 'מתמטיקה')
-
- # Send Notification
- success = send_push_notification(uid, name, category)
-
- if success:
- # Update status
- sug.reference.update({"reminder_sent": True})
- count += 1
-
- logger.info(f"✅ [REMINDER] Processed {count} reminders.")
-
- except Exception as e:
- logger.error(f"❌ [REMINDER] Engine Error: {e}")
-
-if __name__ == "__main__":
- # Ensure Firebase is initialized
- firebase_manager.initialize()
- run_reminder_check()
diff --git a/upload_welcome.py b/upload_welcome.py
deleted file mode 100644
index 34dfa94042e7698fa95c90200541db1ec8710f5c..0000000000000000000000000000000000000000
--- a/upload_welcome.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from firebase_manager import firebase_manager
-import os
-
-# הקובץ שכבר יצרת וקיים בתיקייה
-LOCAL_FILE = "welcome_speech_fixed.wav"
-# הנתיב שבו נשמור אותו בענן (קבוע עבור קובץ הפתיחה)
-DESTINATION = "audio/welcome_teacher_v1.wav"
-
-if os.path.exists(LOCAL_FILE):
- print(f"📡 מעלה את קובץ הפתיחה הקיים ל-Firebase...")
- url = firebase_manager.upload_file(LOCAL_FILE, DESTINATION)
- if url:
- print(f"✅ הקובץ הועלה! הכתובת הקבועה שלו היא:\n{url}")
-else:
- print(f"❌ לא מצאתי את הקובץ {LOCAL_FILE}. וודא שאתה מריץ את הסקריפט מאותה תיקייה.")
\ No newline at end of file
diff --git a/usage_stats.json b/usage_stats.json
deleted file mode 100644
index 80a4a3537cc2f518157d53f62ab2d5ee1c9f9164..0000000000000000000000000000000000000000
--- a/usage_stats.json
+++ /dev/null
@@ -1,53 +0,0 @@
-{
- "2026-02-15": {
- "limit_test_user": 2
- },
- "2026-02-21": {
- "\u05d3\u05d5\u05ea\u05df": 2
- },
- "2026-02-22": {
- "\u05d3\u05d5\u05ea\u05df": 26
- },
- "2026-02-23": {
- "\u05d3\u05d5\u05ea\u05df": 35
- },
- "2026-02-24": {
- "\u05d3\u05d5\u05ea\u05df": 45
- },
- "2026-02-27": {
- "stress_user_1": 23,
- "stress_user_2": 23,
- "stress_user_3": 22,
- "stress_user_4": 22,
- "stress_user_0": 22,
- "\u05d3\u05d5\u05ea\u05df": 13
- },
- "2026-02-28": {
- "\u05d3\u05d5\u05ea\u05df": 11,
- "diagnostic": 7
- },
- "2026-03-01": {
- "\u05d3\u05d5\u05ea\u05df": 17
- },
- "2026-03-02": {
- "\u05d3\u05d5\u05ea\u05df": 11
- },
- "2026-03-03": {
- "\u05d3\u05d5\u05ea\u05df": 30
- },
- "2026-03-04": {
- "\u05d3\u05d5\u05ea\u05df": 27
- },
- "2026-03-05": {
- "\u05d3\u05d5\u05ea\u05df": 19
- },
- "2026-03-06": {
- "\u05d3\u05d5\u05ea\u05df": 11
- },
- "2026-03-07": {
- "\u05d3\u05d5\u05ea\u05df": 7
- },
- "2026-03-08": {
- "\u05d3\u05d5\u05ea\u05df": 8
- }
-}
\ No newline at end of file
diff --git a/utils/image_processor.py b/utils/image_processor.py
deleted file mode 100644
index 139ee2042b4e66e8832877d4b04742c88b0400e5..0000000000000000000000000000000000000000
--- a/utils/image_processor.py
+++ /dev/null
@@ -1,43 +0,0 @@
-import cv2
-import numpy as np
-
-def enhance_image_for_math_ocr(image_bytes: bytes) -> bytes:
- """
- Upscales and binarizes an image to improve OCR accuracy for math equations.
- V5.13.14: Added OpenCV Pre-processing layer as requested.
- """
- try:
- # Decode the image bytes to OpenCV format
- nparr = np.frombuffer(image_bytes, np.uint8)
- img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
-
- if img is None:
- return image_bytes # Fallback if decode fails
-
- # 1. Upscale 2x using Cubic Interpolation for smoother edges
- img = cv2.resize(img, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC)
-
- # 2. Convert to Grayscale
- gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
-
- # 3. Apply slight Gaussian Blur to remove noise before thresholding
- blurred = cv2.GaussianBlur(gray, (5, 5), 0)
-
- # 4. Adaptive Thresholding - turns paper to pure white and ink to pure black
- binary = cv2.adaptiveThreshold(
- blurred,
- 255,
- cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
- cv2.THRESH_BINARY,
- 11, 2
- )
-
- # Encode back to JPEG bytes (using .jpg as requested)
- success, encoded_image = cv2.imencode('.jpg', binary)
- if success:
- return encoded_image.tobytes()
- return image_bytes
-
- except Exception as e:
- print(f"⚠️ [IMAGE ENHANCEMENT FAILED]: {e}. Using original image.")
- return image_bytes
diff --git a/utils/math_utils.py b/utils/math_utils.py
deleted file mode 100644
index dd099289f0d2511d7872c34a75f1b33b149f7e4f..0000000000000000000000000000000000000000
--- a/utils/math_utils.py
+++ /dev/null
@@ -1,62 +0,0 @@
-import re
-
-def sanitize_latex_for_sympy(latex_str: str) -> str:
- """
- מסיר פקודות עיצוב של LaTeX (כמו צבעים) כדי ש-SymPy יוכל לפענח את הביטוי.
- לדוגמה: הופך את \color{red}{5} ל- 5.
- """
- if not latex_str:
- return latex_str
-
- # שלב 1: מחלץ את התוכן מתוך פקודות \color{...}{content}
- cleaned = re.sub(r'\\color\{.*?\}\{(.*?)\}', r'\1', latex_str)
-
- # שלב 2: מוחק פקודות \color{...} גלמודות שלא עוטפות כלום
- cleaned = re.sub(r'\\color\{.*?\}', '', cleaned)
-
- # שלב 3: מחיקת פקודות \text{} (SymPy שונא טקסט חופשי)
- cleaned = re.sub(r'\\text\{.*?\}', '', cleaned)
-
- # שלב 4: ניקוי סוגריים מסוג \left( \right)
- cleaned = cleaned.replace(r'\left', '').replace(r'\right', '')
-
- return cleaned
-
-from typing import List
-
-def aggressive_sympy_sanitizer(latex_str: str) -> List[str]:
- """
- V9.0.1: Cleans dirty LaTeX generated by the LLM before feeding it to SymPy.
- Splits comma-separated equations into a list of pure math strings.
- """
- if not latex_str:
- return []
-
- s = latex_str
-
- # 1. Strip Hebrew Content including surrounding parentheses (e.g. (לכן C))
- # This prevents leaving behind empty shells like "( C)" which crash SymPy.
- s = re.sub(r'\([^)]*[\u0590-\u05FF]+[^)]*\)', '', s)
- s = re.sub(r'[\u0590-\u05FF]+', '', s)
-
- # 2. Strip formatting tags (\text{} and \color{})
- s = re.sub(r'\\text\{.*?\}', '', s)
- s = re.sub(r'\\color\{.*?\}', '', s)
-
- # 3. Replace Logical Arrows with Equals (to maintain equation structure)
- arrows = [r'\Rightarrow', r'\implies', r'\iff', r'\rightarrow']
- for arrow in arrows:
- s = s.replace(arrow, '=')
-
- # --- 🚀 BEGIN HOTFIX V9.0.3: LATEX MULTIPLICATION ---
- s = s.replace(r'\cdot', '*').replace(r'\times', '*')
- # --- END HOTFIX ---
-
- # 4. Split multiple equations
- # SymPy cannot parse "x=0, y=9" as a single AST node.
- # We must split by comma and return a list of clean equations.
- # We also run the existing sanitize_latex_for_sympy on each part.
- expressions = [sanitize_latex_for_sympy(expr.strip()) for expr in s.split(',')]
-
- # Filter out empty strings that might have resulted from the split/regex
- return [e for e in expressions if e]
diff --git a/utils/safe_json.py b/utils/safe_json.py
deleted file mode 100644
index 5a08726fc325a023128484fd0e094e685448be19..0000000000000000000000000000000000000000
--- a/utils/safe_json.py
+++ /dev/null
@@ -1,156 +0,0 @@
-# utils/safe_json.py — V1.0 (CANONICAL JSON EXTRACTOR)
-"""
-CTO Directive: One canonical JSON extractor for the entire server.
-Replaces 3 ad-hoc implementations in orchestrator.py, strategy_manager.py,
-problem_understanding.py, and ocr_strip_engine.py.
-
-Rules:
-1. LOG the RAW LLM string BEFORE any processing (critical for debugging hallucinations).
-2. LaTeX backslash shield applied BEFORE extraction (prevents json.loads crashes).
-3. json_repair applied before json.loads (handles LLM-malformed JSON).
-4. Fail-CLOSED: any failure returns a safe error dict, never raises.
-5. Supports both {} (dict) and [] (array) top-level JSON blocks.
-"""
-
-import re
-import json
-import logging
-from typing import Union
-
-logger = logging.getLogger(__name__)
-
-try:
- from json_repair import repair_json
-except ImportError:
- logger.warning("[SAFE_JSON] json_repair not available. Using raw json.loads fallback.")
- repair_json = lambda x: x # noqa: E731
-
-# Sentinel returned on any unrecoverable failure
-_PARSE_FAILURE = {
- "logic_error": True,
- "error_type": "PARSING_FAILURE",
- "final_answer": "מצטערת, התשובה שיצרתי התבלגנה קצת בדרך. נוכל לנסות שוב? 🔄"
-}
-
-
-def safe_extract_json(
- llm_response_text: str,
- caller: str = "UNKNOWN",
- allow_array: bool = True
-) -> Union[dict, list]:
- """
- Generic, robust JSON extraction from any LLM response string.
-
- Args:
- llm_response_text: Raw text from LLM (may include markdown, prose, LaTeX).
- caller: Identifier for the calling module (used in log tags).
- allow_array: If True, accepts top-level JSON arrays [...] as well as objects {...}.
-
- Returns:
- Parsed dict or list on success.
- _PARSE_FAILURE dict on any failure (never raises).
- """
- # ── Step 1: LOG RAW INPUT (CTO requirement) ────────────────────────────────
- logger.info(
- f"[SAFE_JSON:{caller}] RAW LLM STRING ({len(llm_response_text)} chars): "
- f"{llm_response_text[:600]!r}"
- )
-
- if not llm_response_text or not llm_response_text.strip():
- logger.error(f"[SAFE_JSON:{caller}] Empty LLM response.")
- return dict(_PARSE_FAILURE)
-
- # ── Step 2: Strip markdown fencing (```json ... ```) ──────────────────────
- # This is the most common wrapper the LLM adds despite instructions
- text = re.sub(r'```(?:json)?\s*', '', llm_response_text)
- text = text.replace('```', '')
-
- # ── Step 3: LaTeX Backslash Shield (CRITICAL FOR MATH) ─────────────────────
- # LLMs frequently output raw backslashes for LaTeX (\frac, \ln) which are
- # invalid in JSON strings. We escape them here unless already escaped.
- # Note: We only escape backslashes that are NOT followed by a valid JSON escape char
- # or another backslash.
- shielded = re.sub(r'\\(?![\\"/bfnrtu])', r'\\\\', text)
-
- # ── Step 4: Locate the outermost JSON block (REGEX MANDATE) ────────────────
- # V286.2: We use a robust re.DOTALL search to find the outermost {} or [] block.
- # This ignores any conversational preamble or postamble added by the LLM.
- pattern = r'(\{.*\}|\[.*\])'
- match = re.search(pattern, shielded, re.DOTALL)
-
- if not match:
- logger.error(
- f"[SAFE_JSON:{caller}] No JSON block found via Regex. "
- f"First 200 chars: {llm_response_text[:200]!r}"
- )
- return dict(_PARSE_FAILURE)
-
- extracted = match.group(0)
-
- # ── Step 5: Try raw json.loads first ─────────────────────────────────────
- try:
- parsed = json.loads(extracted)
- logger.info(f"[SAFE_JSON:{caller}] ✅ Parsed successfully (direct).")
- return _sanitize_math_newlines(parsed)
- except json.JSONDecodeError:
- pass # Fall through to json_repair
-
- # ── Step 5b: json_repair + json.loads (for malformed LLM output) ─────────
- try:
- repaired = repair_json(extracted)
- parsed = json.loads(repaired)
- logger.info(f"[SAFE_JSON:{caller}] ✅ Parsed successfully (repair).")
- return _sanitize_math_newlines(parsed)
-
- except Exception as e:
- logger.error(f"[SAFE_JSON:{caller}] json_repair fail: {type(e).__name__} - {e}. Extracted: {extracted[:200]!r}")
-
- # ── Step 6: Last resort — try raw extraction without LaTeX shield ─────────
- # We look for anything that looks like a JSON block
- patterns = [
- r'(\{.*\}|\[.*\])', # Greedy match for outermost block
- r'(\{[^{}]*\}|\[[^\[\]]*\])' # Non-nested match as fallback
- ]
-
- try:
- for pattern in patterns:
- match = re.search(pattern, llm_response_text, re.DOTALL)
- if match:
- parsed = json.loads(repair_json(match.group(0)))
- logger.warning(f"[SAFE_JSON:{caller}] ✅ Recovered via raw extraction (no LaTeX shield).")
- return _sanitize_math_newlines(parsed)
- except Exception:
- pass
-
- logger.error(f"[SAFE_JSON:{caller}] 🚨 All extraction strategies failed. Returning PARSE_FAILURE.")
- return dict(_PARSE_FAILURE)
-
-
-def _sanitize_math_newlines(data: Union[dict, list, str]) -> Union[dict, list, str]:
- """Recursively cleans up newline characters and restores swallowed LaTeX backslashes."""
- if isinstance(data, list):
- return [_sanitize_math_newlines(i) for i in data]
- elif isinstance(data, dict):
- new_dict = {}
- for k, v in data.items():
- if isinstance(v, str):
- # V286.1: Fix swallowed backslashes!
- # When LLM outputs "\frac", JSON parses \f as form feed (\x0c). \beta as backspace (\x08).
- v = v.replace('\x0c', r'\f')
- v = v.replace('\x08', r'\b')
- v = v.replace('\t', r'\t')
- v = v.replace('\r', r'\r')
-
- # \n might be intended for \neq, \nu, \nabla, \normal, etc.
- import re
- v = re.sub(r'\n(eq|u|abla|ormal| left| right)', r'\\n\1', v)
-
- # Special case for math-heavy keys: ensure no \n breaks rendering
- if k in ['block_math', 'math_block', 'latex', 'equation', 'content'] or '$' in v:
- v = v.replace('\n', ' ')
-
- new_dict[k] = v
- else:
- new_dict[k] = _sanitize_math_newlines(v)
- return new_dict
- return data
diff --git a/validators.py b/validators.py
deleted file mode 100644
index d286c4445dd470ece0475c4825f00a28796d9ef2..0000000000000000000000000000000000000000
--- a/validators.py
+++ /dev/null
@@ -1,410 +0,0 @@
-# validators.py - V231.12
-# Python-based mathematical validation for BuddyMath
-# Uses SymPy to verify LLM outputs are mathematically correct
-
-"""
-Mathematical Validators for BuddyMath
-Follows Iron Law #2: MATHEMATICAL ACCURACY OVER SPEED
-
-Each validator uses SymPy to verify LLM output is correct.
-If validation fails, Strategy Manager triggers retry.
-"""
-
-import ast
-from sympy import symbols, expand, diff, sympify, sqrt, simplify, Eq
-from sympy.parsing.sympy_parser import parse_expr
-import re
-
-# ==================== GEOMETRY VALIDATORS ====================
-
-def validate_circle_equation(llm_output: dict, data_anchor: dict) -> dict:
- """
- Validate circle equation using SymPy.
-
- Args:
- llm_output: {"equation": "(x-3)² + (y-5)² = 25"}
- data_anchor: {"center": (3, 5), "radius": 5}
-
- Returns:
- {"valid": bool, "error": str or None}
- """
- try:
- x, y = symbols('x y')
-
- # Expected equation
- center = data_anchor.get('center', (0, 0))
- radius = data_anchor.get('radius', 0)
-
- if isinstance(center, str):
- # V8.6.8: Use ast.literal_eval instead of eval() for safety against NameError: name 'x' is not defined
- try:
- center = ast.literal_eval(center)
- except Exception as e:
- # If literal_eval fails (e.g. Center="A(3,5)"), try regex fallback
- match = re.search(r'\(([^,]+),([^)]+)\)', center)
- if match:
- center = (float(match.group(1)), float(match.group(2)))
- else:
- return {"valid": False, "error": f"Invalid center format: {center}"}
-
- expected = (x - center[0])**2 + (y - center[1])**2 - radius**2
-
- # LLM equation (might be under 'equation' or 'solution' if using general prompt)
- eq_str = llm_output.get('equation') or llm_output.get('solution', '')
-
- # If still empty, validation fails
- if not eq_str:
- return {"valid": False, "error": "No equation or solution found in LLM output"}
-
- # Convert to SymPy
- eq_str = eq_str.replace('²', '**2').replace('^', '**')
-
- # Parse both sides
- if '=' in eq_str:
- left, right = eq_str.split('=')
- actual = sympify(left) - sympify(right)
- else:
- actual = sympify(eq_str)
-
- # Check if equivalent
- diff_expr = expand(expected - actual)
-
- if diff_expr == 0:
- return {"valid": True, "error": None}
- else:
- return {
- "valid": False,
- "error": f"Equation mismatch. Expected: {expected} = 0, Got: {actual} = 0"
- }
-
- except Exception as e:
- return {"valid": False, "error": f"Validation error: {str(e)}"}
-
-
-def validate_line_equation(llm_output: dict, data_anchor: dict) -> dict:
- """
- Validate line equation.
-
- Checks:
- - If two points given, line passes through both
- - If slope + point given, equation is correct
- """
- try:
- x, y = symbols('x y')
-
- # Parse LLM equation (might be under 'equation' or 'solution' if using general prompt)
- eq_str = llm_output.get('equation') or llm_output.get('solution', '')
-
- if not eq_str:
- return {"valid": False, "error": "No equation or solution found in LLM output"}
-
- eq_str = eq_str.replace('^', '**')
-
- # Get y as function of x
- if '=' in eq_str:
- left, right = eq_str.split('=')
- if 'y' in left:
- y_expr = sympify(right)
- else:
- y_expr = sympify(left)
- else:
- return {"valid": False, "error": "No '=' in equation"}
-
- # Validate against data
- points = data_anchor.get('points', [])
-
- for point_str in points:
- # Parse "A(3,4)" to (3, 4)
- match = re.search(r'\(([^,]+),([^)]+)\)', point_str)
- if match:
- px, py = float(match.group(1)), float(match.group(2))
-
- # Check if point satisfies equation
- y_val = y_expr.subs(x, px)
-
- if abs(float(y_val) - py) > 0.01:
- return {
- "valid": False,
- "error": f"Point ({px},{py}) not on line"
- }
-
- return {"valid": True, "error": None}
-
- except Exception as e:
- return {"valid": False, "error": f"Validation error: {str(e)}"}
-
-
-def validate_locus_equation(llm_output: dict, data_anchor: dict) -> dict:
- """
- Validate geometric locus equation.
-
- Since loci vary, this performs a basic sanity check:
- 1. Verifies the equation is well-formed (can be parsed by SymPy)
- 2. If points are provided in data_anchor, checks if they satisfy the equation.
- 3. Prevents algebraically impossible equations.
- """
- try:
- x, y = symbols('x y')
-
- # Parse LLM equation (might be under 'equation' or 'solution' if using general prompt)
- eq_str = llm_output.get('equation') or llm_output.get('solution', '')
-
- if not eq_str:
- return {"valid": False, "error": "No equation or solution found in LLM locus output"}
-
- eq_str = eq_str.replace('²', '**2').replace('^', '**')
-
- if '=' not in eq_str:
- return {"valid": False, "error": "No '=' in locus equation"}
-
- left, right = eq_str.split('=')
-
- # Ensure it parses as a valid mathematical expression
- try:
- actual_expr = sympify(left) - sympify(right)
- except Exception as e:
- return {"valid": False, "error": f"Malformed locus equation: {str(e)}"}
-
- # Basic check: locus must depend on x and/or y
- free_symbols = actual_expr.free_symbols
- if x not in free_symbols and y not in free_symbols:
- return {"valid": False, "error": "Locus equation does not contain x or y"}
-
- # Validate against provided locus points if any
- points = data_anchor.get('points', [])
- for point_str in points:
- match = re.search(r'\(([^,]+),([^)]+)\)', point_str)
- if match:
- px, py = float(match.group(1)), float(match.group(2))
-
- # Check if locus point satisfies equation
- val = actual_expr.subs({x: px, y: py})
- if abs(float(val)) > 0.01:
- return {
- "valid": False,
- "error": f"Given locus point ({px},{py}) does not satisfy equation"
- }
-
- return {"valid": True, "error": None}
-
- except Exception as e:
- return {"valid": False, "error": f"Locus validation error: {str(e)}"}
-
-
-def validate_distance(llm_output: dict, data_anchor: dict) -> dict:
- """Validate distance calculation between two points."""
- try:
- # Parse points
- point_a = data_anchor.get('point_a')
- point_b = data_anchor.get('point_b')
-
- if isinstance(point_a, str):
- match = re.search(r'\(([^,]+),([^)]+)\)', point_a)
- if match:
- point_a = (float(match.group(1)), float(match.group(2)))
-
- if isinstance(point_b, str):
- match = re.search(r'\(([^,]+),([^)]+)\)', point_b)
- if match:
- point_b = (float(match.group(1)), float(match.group(2)))
-
- # Calculate expected distance
- dx = point_b[0] - point_a[0]
- dy = point_b[1] - point_a[1]
- expected = float(sqrt(dx**2 + dy**2))
-
- # LLM distance
- actual = float(llm_output.get('distance', 0))
-
- if abs(expected - actual) < 0.01:
- return {"valid": True, "error": None}
- else:
- return {
- "valid": False,
- "error": f"Distance mismatch. Expected: {expected:.2f}, Got: {actual:.2f}"
- }
-
- except Exception as e:
- return {"valid": False, "error": f"Validation error: {str(e)}"}
-
-
-# ==================== CALCULUS VALIDATORS ====================
-
-def validate_derivative(llm_output: dict, data_anchor: dict) -> dict:
- """
- Validate derivative using SymPy.
-
- Works for all derivative types (power, quotient, product, chain).
- """
- try:
- x = symbols('x')
-
- # Get original function
- func_str = data_anchor.get('function', '')
- if not func_str:
- # Try to reconstruct from numerator/denominator
- num = data_anchor.get('numerator', '')
- den = data_anchor.get('denominator', '')
- if num and den:
- func_str = f"({num})/({den})"
-
- # Parse function
- func_str = func_str.replace('^', '**').replace('²', '**2')
- f = sympify(func_str)
-
- # Calculate expected derivative
- expected = diff(f, x)
-
- # Parse LLM derivative
- deriv_str = llm_output.get('derivative', '')
- deriv_str = deriv_str.replace('^', '**').replace('²', '**2')
- actual = sympify(deriv_str)
-
- # Simplify and compare
- diff_expr = simplify(expected - actual)
-
- if diff_expr == 0:
- return {"valid": True, "error": None}
- else:
- return {
- "valid": False,
- "error": f"Derivative mismatch. Expected: {expected}, Got: {actual}"
- }
-
- except Exception as e:
- return {"valid": False, "error": f"Validation error: {str(e)}"}
-
-
-def validate_extrema(llm_output: dict, data_anchor: dict) -> dict:
- """
- Validate extrema (max/min points).
-
- Checks:
- 1. f'(x) = 0 at extrema points
- 2. f''(x) confirms type (max/min)
- """
- try:
- x = symbols('x')
-
- # Parse function
- func_str = data_anchor.get('function', '')
- func_str = func_str.replace('^', '**').replace('²', '**2')
- f = sympify(func_str)
-
- # Calculate derivatives
- f_prime = diff(f, x)
- f_double_prime = diff(f_prime, x)
-
- # Check each extremum
- extrema = llm_output.get('extrema', [])
-
- for ext in extrema:
- x_val = ext.get('x')
- ext_type = ext.get('type') # 'max' or 'min'
-
- # Check f'(x) = 0
- f_prime_val = float(f_prime.subs(x, x_val))
- if abs(f_prime_val) > 0.01:
- return {
- "valid": False,
- "error": f"f'({x_val}) = {f_prime_val:.3f}, not 0"
- }
-
- # Check f''(x) for type
- f_double_prime_val = float(f_double_prime.subs(x, x_val))
-
- if ext_type == 'min' and f_double_prime_val <= 0:
- return {
- "valid": False,
- "error": f"f''({x_val}) = {f_double_prime_val:.3f}, should be > 0 for min"
- }
-
- if ext_type == 'max' and f_double_prime_val >= 0:
- return {
- "valid": False,
- "error": f"f''({x_val}) = {f_double_prime_val:.3f}, should be < 0 for max"
- }
-
- return {"valid": True, "error": None}
-
- except Exception as e:
- return {"valid": False, "error": f"Validation error: {str(e)}"}
-
-
-# ==================== ALGEBRA VALIDATORS ====================
-
-def validate_equation_solution(llm_output: dict, data_anchor: dict) -> dict:
- """Validate solution to equation (linear, quadratic, etc.)"""
- try:
- x = symbols('x')
-
- # Parse equation
- eq_str = data_anchor.get('equation', '')
- eq_str = eq_str.replace('^', '**').replace('²', '**2')
-
- if '=' in eq_str:
- left, right = eq_str.split('=')
- equation = sympify(left) - sympify(right)
- else:
- equation = sympify(eq_str)
-
- # Get LLM solution(s)
- solution = llm_output.get('solution')
- solutions = llm_output.get('solutions', [])
-
- if solution is not None:
- solutions = [solution]
-
- # Check each solution
- for sol in solutions:
- result = float(equation.subs(x, sol))
- if abs(result) > 0.01:
- return {
- "valid": False,
- "error": f"x = {sol} does not satisfy equation (result: {result:.3f})"
- }
-
- return {"valid": True, "error": None}
-
- except Exception as e:
- return {"valid": False, "error": f"Validation error: {str(e)}"}
-
-
-# ==================== VALIDATOR REGISTRY ====================
-
-VALIDATORS = {
- "CIRCLE_EQUATION": validate_circle_equation,
- "LINE_EQUATION": validate_line_equation,
- "DISTANCE_FORMULA": validate_distance,
- "GEOMETRIC_LOCUS": validate_locus_equation,
- "DERIVATIVE_POWER": validate_derivative,
- "DERIVATIVE_QUOTIENT": validate_derivative,
- "DERIVATIVE_PRODUCT": validate_derivative,
- "DERIVATIVE_CHAIN": validate_derivative,
- "INVESTIGATION_EXTREMA": validate_extrema,
- "LINEAR_EQUATION": validate_equation_solution,
- "QUADRATIC_EQUATION": validate_equation_solution,
-}
-
-
-def get_validator(topic_id: str):
- """Get validator function for topic, or None if no validator"""
- return VALIDATORS.get(topic_id)
-
-
-# ==================== USAGE EXAMPLE ====================
-
-if __name__ == "__main__":
- # Test circle equation
- llm_out = {"equation": "(x-3)^2 + (y-5)^2 = 25"}
- data = {"center": (3, 5), "radius": 5}
-
- result = validate_circle_equation(llm_out, data)
- print(f"Circle validation: {result}")
-
- # Test derivative
- llm_out = {"derivative": "3*x**2"}
- data = {"function": "x^3"}
-
- result = validate_derivative(llm_out, data)
- print(f"Derivative validation: {result}")
diff --git a/visuals.py b/visuals.py
deleted file mode 100644
index f2b6dfb1b8385deac4f21791250d5a881e7a0a96..0000000000000000000000000000000000000000
--- a/visuals.py
+++ /dev/null
@@ -1,354 +0,0 @@
-# visuals.py - V277.0 (Finer Lines + Grid Optimization)
-import matplotlib.pyplot as plt
-import numpy as np
-import io, base64, re, threading
-from concurrent.futures import ThreadPoolExecutor, TimeoutError
-from sympy import sympify, symbols, lambdify, Abs, sin, cos, tan, sqrt, log, ln, exp, solve
-
-print("✅ 🟢 [BIT-LOG: Visuals V277.0] - Finer Lines + Grid Optimization")
-
-def clean_latex_for_sympy(latex_str):
- """V318.0: Conerts |x| to Abs(x) and \ln to log for SymPy compatibility."""
- cleaned = re.sub(r'\|(.*?)\|', r'Abs(\1)', latex_str)
- cleaned = cleaned.replace(r'\ln', 'log')
- return cleaned
-
- # 1. Strip Hebrew Characters and parenthetical Hebrew explanations (V9.0.1)
- expr_str = re.sub(r'\([^)]*[\u0590-\u05FF]+[^)]*\)', '', expr_str)
- expr_str = re.sub(r'[\u0590-\u05FF]', '', expr_str)
-
- # 1.1 Replace Logical Arrows with Equals (V9.0.1)
- arrows = [r'\Rightarrow', r'\implies', r'\iff', r'\rightarrow']
- for arrow in arrows:
- expr_str = expr_str.replace(arrow, '=')
-
- # 2. Cut multiple equations
- if r'\\' in expr_str:
- parts = expr_str.split(r'\\')
- expr_str = max(parts, key=len)
-
- text = expr_str.replace(r'\left', '').replace(r'\right', '')
-
- # 3. Fraction fix (Safe version for nested braces with Kill-Switch)
- loop_counter = 0
- max_loops = 15
- while r'\frac' in text and loop_counter < max_loops:
- old_text = text
- # Regex שמאפשר רמה אחת של סוגריים פנימיים (למשל עבור חזקות)
- text = re.sub(r'\\frac\s*\{((?:[^{}]|\{[^{}]*\})*)\}\s*\{((?:[^{}]|\{[^{}]*\})*)\}', r'(\1)/(\2)', text)
- if old_text == text:
- # חילוץ חירום במקרה של שבירה
- text = text.replace(r'\frac', '').replace('{', '(').replace('}', ')')
- break
- loop_counter += 1
-
- # 4. BASIC NORMALIZATION (The Foundation)
- text = text.replace(r'\sin', 'sin').replace(r'\cos', 'cos').replace(r'\tan', 'tan')
- text = text.replace(r'\ln', 'ln').replace(r'\log', 'log').replace(r'\exp', 'exp')
- text = text.replace(r'\sqrt', 'sqrt').replace(r'\pi', 'pi').replace(r'\theta', 'theta')
-
- # V281.0: CRITICAL FIX - Implicit multiplication before parentheses and functions
- # 1. Add parentheses to functions missing them: ln x -> ln(x), ln Abs(x) -> ln(Abs(x))
- # Match function + (identifier or Abs(...) or nested function)
- text = re.sub(r'(sin|cos|tan|ln|log|exp|sqrt)\s*([a-zA-Z\d]+(?:\([^)]*\))?)', r'\1(\2)', text)
-
- # 2. V276.0 CRITICAL FIX: \cdot → * (BEFORE ^ conversion!)
- text = text.replace(r'\cdot', '*').replace('cdot', '*')
-
- # 5. ABSOLUTE VALUE FIX (V309.5: Clean Paired Pipes |x| -> Abs(x))
- # Using a more surgical regex to avoid fragments like "lnAbsg(x)*Abs"
- loop_counter = 0
- while '|' in text and text.count('|') >= 2 and loop_counter < 10:
- # Match |...| only if it's not part of another command or broken
- text = re.sub(r'\|([^|]+)\|', r'Abs(\1)', text)
- loop_counter += 1
-
- # Final cleanup: Remove any stray single pipes that didn't form a pair
- text = text.replace('|', '')
-
- # Convert syntax
- text = text.replace('^', '**').replace('{', '(').replace('}', ')')
- text = text.replace('f(x)=', '').replace('y=', '')
-
- # 6. ADVANCED FIXES
- text = text.replace('((x))', '(x)')
-
- # V230.4 FIX: Trig Powers
- text = re.sub(r'(sin|cos|tan)\*\*\(?(\d+)\)?\(([^)]*)\)', r'(\1(\3))**\2', text)
-
- # 6. Cleanup & Whitelist
- blocklist = ["calculate", "find", "solve", "graph", ":"]
- for w in blocklist: text = re.sub(rf'\b{w}\b', '', text, flags=re.IGNORECASE)
-
- # V281.0: REFINED IMPLICIT MULTIPLICATION (Fixes "Pow object is not callable")
- # x(x+1) -> x*(x+1), 2(x) -> 2*(x), (x)(y) -> (x)*(y)
- text = re.sub(r'(\d|[a-zA-Z]|\))(\()', r'\1*\2', text)
- # 2x -> 2*x, )x -> )*x
- text = re.sub(r'(\d|\))([a-zA-Z\(])', r'\1*\2', text)
- # (x)y -> (x)*y
- text = re.sub(r'(\))([a-zA-Z])', r'\1*\2', text)
-
- # Whitelist → replaced with targeted blocklist to protect all math-relevant chars
- # (old whitelist was missing 'f', 'h', 'k', 't', 'r' — caused \frac remnants to corrupt)
- text = re.sub(r'[\u0590-\u05FF\u200B-\u200D\uFEFF\'"`;@#%&!?]', '', text) # Hebrew + noise
- text = re.sub(r'\\[a-zA-Z]+', '', text) # Any un-converted LaTeX commands
-
- return text
-
-# V290.0: Global Timeout Wrapper for SymPy
-def _run_with_timeout(func, args, timeout_duration=2.0, default_value=None):
- with ThreadPoolExecutor(max_workers=1) as executor:
- future = executor.submit(func, *args)
- try:
- return future.result(timeout=timeout_duration)
- except Exception as e:
- print(f"⚠️ [VISUALS TIMEOUT] Operation failed or timed out: {e}")
- return default_value
-
-def generate_plot(latex_input: str, context_text: str = "", geometric_entities: dict = None) -> str:
- try:
- print(f"📈 [VISUALS] generate_plot called with: latex='{latex_input}', geo_entities={list(geometric_entities.keys()) if geometric_entities else None}")
- # We still sanitize the full input, but we don't split it by comma unconditionally
- # geometry check uses the first element to avoid implicit matches on secondary graphs
- first_expr = latex_input.split(',')[0].strip()
- safe_expr_first = sanitize_math_for_sympy(first_expr)
-
- # We sanitize the FULL input to pass to _plot_func
- safe_expr_full = sanitize_math_for_sympy(latex_input)
-
- # בדיקה חכמה: האם באמת יש ישויות גיאומטריות מלאות?
- has_real_geo = False
- if geometric_entities:
- has_real_geo = any(len(v) > 0 for v in geometric_entities.values() if isinstance(v, list))
-
- is_implicit = "=" in safe_expr_first or ("x" in safe_expr_first and "y" in safe_expr_first and "**2" in safe_expr_first) or has_real_geo
-
- if is_implicit:
- return _plot_geo(safe_expr_first, context_text, geometric_entities)
- return _plot_func(safe_expr_full)
- except Exception as e:
- print(f"📈 🔴 [BIT-LOG] CRITICAL PLOT ERROR: {e}")
- import traceback
- traceback.print_exc()
- return None
-
-def _plot_func(expr_str):
- try:
- x = symbols('x')
- local_dict = {"sin": sin, "cos": cos, "tan": tan, "sqrt": sqrt, "Abs": Abs, "pi": np.pi, "ln": ln, "log": log, "exp": exp, "e": exp(1)}
-
- # 1. הגדלת שטח הקנבס כך שהקווים יהיו דקים וחדים יותר ביחס לתמונה (Scaling)
- plt.figure(figsize=(8.0, 5.0), dpi=300)
- plt.style.use('dark_background')
-
- # 2. הוסר plt.xkcd() לחלוטין כדי למנוע מריחות ועובי גיר מוגזם
-
- ax = plt.gca()
- ax.set_facecolor('#1A1A1B') # צבע פחם עמוק ללוח
-
- # 3. הסרת מסגרת הגרף למראה נקי ומרחף
- ax.spines['top'].set_visible(False)
- ax.spines['right'].set_visible(False)
- ax.spines['bottom'].set_visible(False)
- ax.spines['left'].set_visible(False)
-
- x_vals = np.linspace(-10, 10, 500)
- expressions = [e.strip() for e in expr_str.split(',') if e.strip()]
-
- colors = ['#A8E6CF', '#FFD3B6', '#D4A5FF'] # פסטל
- plotted_any = False
-
- for idx, single_expr in enumerate(expressions):
- try:
- # V318.0: Robust sanitization within the loop
- single_expr = sanitize_math_for_sympy(single_expr)
- if not single_expr: continue
-
- # V8.6.9: Explicit try-except for SYMPY_PARSE_ERROR prevention
- try:
- expr = _run_with_timeout(sympify, (single_expr, None, local_dict), timeout_duration=2.0)
- except Exception as e:
- print(f"⚠️ [VISUALS] SymPy Parse Error on '{single_expr}': {e}")
- expr = None
-
- if not expr: continue
-
- # Ticket 1 Fix: Handle free symbols (parameters like a, b, k)
- # We only want to plot with respect to 'x', so replace everything else with 1
- expr_symbols = expr.free_symbols
- params = [s for s in expr_symbols if s != x]
- if params:
- print(f"🛠️ [VISUALS] Substituting parameters {params} with 1 for sketch.")
- expr = expr.subs({s: 1 for s in params})
-
- # Final check if the result is still symbolic
- if not expr.is_finite: continue
-
- f_np = lambdify(x, expr, modules=['numpy'])
- y_vals = f_np(x_vals)
-
- y_vals = np.array(y_vals, dtype=float)
- y_vals[np.abs(y_vals) > 20] = np.nan
-
- # קו הפונקציה: בולט וברור אך לא מרוח
- plt.plot(x_vals, y_vals,
- color=colors[idx % len(colors)],
- linestyle='-',
- linewidth=2.0,
- zorder=5)
- plotted_any = True
- except Exception as inner_err:
- continue
-
- if not plotted_any:
- plt.close()
- return None
-
- # ציורים וצירים: עדינים וכמעט שקופים
- plt.axhline(0, color='white', alpha=0.4, linestyle='-', linewidth=0.8, zorder=2)
- plt.axvline(0, color='white', alpha=0.4, linestyle='-', linewidth=0.8, zorder=2)
- plt.grid(color='white', linestyle='--', alpha=0.1, linewidth=0.5, zorder=1)
-
- plt.tight_layout(pad=0.5)
-
- return _plt_to_svg()
- except Exception as e:
- print(f"📈 🔴 [BIT-LOG] Func Plot Error: {e}")
- return None
-
-def _plot_geo(solution_expr, context_text, geometric_entities=None):
- """V276.0: Enhanced geometry plotting."""
- try:
- # 1. הגדלת שטח הקנבס והסרת xkcd למראה נקי
- plt.figure(figsize=(8.0, 8.0), dpi=300)
- plt.style.use('dark_background')
-
- ax = plt.gca()
- ax.set_facecolor('#1A1A1B') # צבע פחם עמוק ללוח
-
- # 2. הסרת מסגרת הגרף
- ax.spines['top'].set_visible(False)
- ax.spines['right'].set_visible(False)
- ax.spines['bottom'].set_visible(False)
- ax.spines['left'].set_visible(False)
-
- if geometric_entities:
- print(f"📈 🟢 [BIT-LOG] Using Explicit Geometric Entities: {geometric_entities.keys()}")
-
- for circ in geometric_entities.get('circles', []):
- try:
- c = plt.Circle((circ['center_x'], circ['center_y']), circ['radius'],
- color='#00D9FF', fill=False, linewidth=1.5)
- ax.add_patch(c)
- except: pass
-
- point_map = {}
- for pt in geometric_entities.get('points', []):
- try:
- px, py = float(pt['x']), float(pt['y'])
- label = pt.get('label', '')
- point_map[label] = (px, py)
- plt.plot(px, py, 'o', color='#FF4B4B', markersize=6)
- if label:
- plt.text(px + 0.3, py + 0.3, label, color='white', fontsize=10, fontweight='bold')
- except: pass
-
- for seg in geometric_entities.get('segments', []):
- try:
- p1 = point_map.get(seg['start'])
- p2 = point_map.get(seg['end'])
- if p1 and p2:
- plt.plot([p1[0], p2[0]], [p1[1], p2[1]], '-', color=seg.get('color', '#FFD700'), linewidth=1.5)
- except: pass
-
- if not geometric_entities:
- circle_match = re.search(r'\(x\s*-\s*(\d+)\)\s*\*\*\s*2\s*\+\s*\(y\s*-\s*(\d+)\)\s*\*\*\s*2\s*=\s*(\d+)', context_text)
- if not circle_match:
- circle_match = re.search(r'M\((\d+),(\d+)\).*?r.*?=.*?(\d+)', context_text)
-
- if circle_match:
- try:
- center_x = float(circle_match.group(1))
- center_y = float(circle_match.group(2))
- radius = float(circle_match.group(3))
- if radius > 20:
- radius = np.sqrt(radius)
- circle = plt.Circle((center_x, center_y), radius, color='#00D9FF', fill=False, linewidth=1.5)
- ax.add_patch(circle)
- except: pass
-
- points = re.findall(r'([A-Z])\((\d+),(\d+)\)', context_text)
- for label, px, py in points:
- px, py = float(px), float(py)
- plt.plot(px, py, 'o', color='#FF4B4B', markersize=6)
- plt.text(px + 0.3, py + 0.3, label, color='white', fontsize=10, fontweight='bold')
-
- lines = re.findall(r'y\s*=\s*([-+]?\d+/\d+)x\s*([-+]\s*\d+)', context_text)
- for slope_str, intercept_str in lines:
- try:
- if '/' in slope_str:
- num, den = slope_str.split('/')
- slope = float(num) / float(den)
- else:
- slope = float(slope_str)
- intercept = float(intercept_str.replace(' ', ''))
- x_vals = np.linspace(-2, 12, 100)
- y_vals = slope * x_vals + intercept
- plt.plot(x_vals, y_vals, '-', color='#FFD700', linewidth=1.5, alpha=0.8)
- except: pass
-
- if "=" in solution_expr:
- parts = solution_expr.split("=")
- eq_str = f"({parts[0]}) - ({parts[1]})"
- else:
- eq_str = solution_expr
-
- try:
- x, y = symbols('x y')
- # V290.0: Watchdog for Geo Sympify & Contour
- # V8.6.9: Local try-except for crash prevention
- try:
- expr = _run_with_timeout(sympify, (eq_str, None, {"sin": sin, "cos": cos, "tan": tan, "sqrt": sqrt, "Abs": Abs, "pi": np.pi, "ln": ln, "log": log, "exp": exp, "e": exp(1)}), timeout_duration=1.5)
- except Exception as e:
- print(f"⚠️ [VISUALS] Geo SymPy Parse Error on '{eq_str}': {e}")
- expr = None
-
- if expr:
- f_np = _run_with_timeout(lambdify, ((x, y), expr, 'numpy'), timeout_duration=1.0)
- if f_np:
- r = np.linspace(-2, 12, 400)
- X, Y = np.meshgrid(r, r)
- plt.contour(X, Y, f_np(X, Y), levels=[0], colors='#00FF00', linewidths=1.5)
- except:
- pass
-
- # צירים וגריד עדינים (בדומה ל-_plot_func)
- plt.axhline(0, color='white', alpha=0.3, linewidth=0.6, zorder=2)
- plt.axvline(0, color='white', alpha=0.3, linewidth=0.6, zorder=2)
- plt.grid(True, alpha=0.1, linewidth=0.4, linestyle='--')
-
- plt.gca().set_aspect('equal', adjustable='box')
- plt.xlim(-2, 12)
- plt.ylim(-2, 12)
- return _plt_to_svg()
- except Exception as e:
- print(f"📈 🔴 [BIT-LOG] Geo Plot Error: {e}")
- return None
-
-def _plt_to_svg():
- buf = io.StringIO()
- # bbox_inches='tight' מונע חיתוך של שולי הגרף במסכים קטנים
- plt.savefig(buf, format='svg', transparent=True, bbox_inches='tight', pad_inches=0.1)
- plt.close()
-
- svg_str = buf.getvalue()
-
- # 🧼 V290.1: ניקוי ה-SVG עבור flutter_svg
- # הסרת תגיות metadata ו-style שגורמות לאזהרות ב-Flutter
- import re
- svg_str = re.sub(r'.*?', '', svg_str, flags=re.DOTALL)
- # הערה: אנחנו משאירים את ה-style אם הוא חיוני, אבל flutter_svg לפעמים מתקשה איתו.
- # במקרה של Matplotlib, ה-style בדרך כלל מכיל הגדרות פונטים שלא קיימות ב-mobile.
- svg_str = re.sub(r'', '', svg_str, flags=re.DOTALL)
-
- return svg_str
diff --git a/weekly_ocr_audit.md b/weekly_ocr_audit.md
deleted file mode 100644
index a53e59d879864ded0056a0a645d49abe06281de6..0000000000000000000000000000000000000000
--- a/weekly_ocr_audit.md
+++ /dev/null
@@ -1,34 +0,0 @@
-# weekly_ocr_audit.md — V7.2.3 Hard Freeze
-# Run every Sunday. Log results in /logs/ocr_audit_YYYY-MM-DD.json
-# DO NOT modify OCR algorithm during Hard Freeze — observe only.
-
-## Weekly OCR Audit Checklist
-
-### 1. ROI Boundary Check
-- [ ] Pull last 7 days of requests where `ocr_confidence < 0.7`
-- [ ] Check `roi_coords` in telemetry logs — confirm bounding box includes full math expression (not clipped)
-- [ ] Flag any session where > 20% of crops have missing top/bottom edges
-
-### 2. Confidence Distribution
-- [ ] Count requests bucketed by confidence: `[0.0-0.5)`, `[0.5-0.7)`, `[0.7-1.0]`
-- [ ] Alert if `[0.0-0.5)` bucket grows by > 10% week-over-week (image quality regression)
-- [ ] Log camera model or device type if available (mobile vs. tablet)
-
-### 3. Perspective Correction Aggressiveness
-- [ ] Sample 20 random images from sessions with `confidence < 0.7`
-- [ ] Visually check whether perspective warp over-corrected (stretched digits)
-- [ ] Note threshold — current: `warp_threshold = 0.15` — do not change during freeze
-
-### 4. Leakage-from-OCR Correlation
-- [ ] Cross-reference weeks where `renderer.leakage_fail` spiked with OCR confidence averages
-- [ ] Hypothesis: low OCR quality → garbled math string → Planner generates unusual Enum sequences
-- [ ] Document in audit log for CTO review
-
-### 5. Sign-off
-- [ ] Audit logged to `/logs/ocr_audit_YYYY-MM-DD.json`
-- [ ] Slack update to `#buddymath-monitoring`
-- [ ] Any findings requiring algorithm change → open ticket for Phase 2 (post-freeze)
-
----
-> CTO Note (V7.2.3): Do not modify the OCR algorithm during Hard Freeze.
-> Changes allowed only after full regression (chaos_test.py 18/18) + CTO sign-off.