Fix: Quota logic for admins and updated firestore rules
Browse files- deploy_dev.ps1 +31 -0
- deploy_dev.sh +40 -0
- deploy_hf +1 -1
- exercise_generator.py +89 -0
- find_admin.py +16 -0
- find_dotan.py +25 -0
- firebase_manager.py +49 -0
- fix_dotan_balance_script.py +54 -0
- fix_now.py +37 -0
- gcs_lifecycle_rule.json +11 -0
- generate_dotan_report.py +97 -0
- main.py +19 -6
- orchestrator.py +143 -1
- quota_system_v2.py +41 -25
- report_aggregator.py +70 -0
- report_delivery_service.py +78 -0
- report_generator.py +51 -234
- report_template.html +58 -0
- tutor_reminder_engine.py +94 -0
deploy_dev.ps1
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
$PROJECT_ID = "buddy-math-dev"
|
| 2 |
+
$SERVICE_NAME = "buddy-math-server"
|
| 3 |
+
$REGION = "europe-west3"
|
| 4 |
+
$IMAGE_NAME = "gcr.io/$PROJECT_ID/$SERVICE_NAME"
|
| 5 |
+
|
| 6 |
+
Write-Host "--- Starting Deployment for BuddyMath Server (DEV) ---" -ForegroundColor Cyan
|
| 7 |
+
|
| 8 |
+
# 1. Check for gcloud
|
| 9 |
+
if (!(Get-Command gcloud -ErrorAction SilentlyContinue)) {
|
| 10 |
+
Write-Error "❌ gcloud CLI not found."
|
| 11 |
+
exit
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
# 2. Set Project
|
| 15 |
+
gcloud config set project $PROJECT_ID
|
| 16 |
+
|
| 17 |
+
# 3. Build Image
|
| 18 |
+
Write-Host "Building Image..."
|
| 19 |
+
gcloud builds submit --tag $IMAGE_NAME .
|
| 20 |
+
|
| 21 |
+
# 4. Deploy to Cloud Run (Single Line Command to avoid Backtick errors)
|
| 22 |
+
Write-Host "Deploying to Cloud Run..."
|
| 23 |
+
gcloud run deploy $SERVICE_NAME --image $IMAGE_NAME --platform managed --region $REGION --allow-unauthenticated --set-env-vars "ENV=development,OCR_STRIP_MODE=development"
|
| 24 |
+
|
| 25 |
+
Write-Host "Deployment Complete!" -ForegroundColor Green
|
| 26 |
+
|
| 27 |
+
# 5. Storage Lifecycle Rule
|
| 28 |
+
Write-Host "Applying Storage Lifecycle Rule..." -ForegroundColor Yellow
|
| 29 |
+
gcloud storage buckets update gs://buddy-math-dev.firebasestorage.app --lifecycle-file=gcs_lifecycle_rule.json
|
| 30 |
+
|
| 31 |
+
Write-Host "DONE: Everything is up and running!" -ForegroundColor Green
|
deploy_dev.sh
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# deploy_dev.sh - Tutor Mode Deployment Script (DEV)
|
| 3 |
+
# V3.18.0 - Includes Phase C Environment Variables
|
| 4 |
+
|
| 5 |
+
PROJECT_ID="buddy-math-dev"
|
| 6 |
+
SERVICE_NAME="buddy-math-server"
|
| 7 |
+
REGION="europe-west3" # Change if needed
|
| 8 |
+
IMAGE_NAME="gcr.io/$PROJECT_ID/$SERVICE_NAME"
|
| 9 |
+
|
| 10 |
+
echo "🚀 Starting Deployment for BuddyMath Server (DEV)..."
|
| 11 |
+
|
| 12 |
+
# 1. Check for gcloud
|
| 13 |
+
if ! command -v gcloud &> /dev/null
|
| 14 |
+
then
|
| 15 |
+
echo "❌ gcloud CLI not found. Please install it: https://cloud.google.com/sdk/docs/install"
|
| 16 |
+
exit
|
| 17 |
+
fi
|
| 18 |
+
|
| 19 |
+
# 2. Set Project
|
| 20 |
+
echo "📍 Setting GCP Project to $PROJECT_ID..."
|
| 21 |
+
gcloud config set project $PROJECT_ID
|
| 22 |
+
|
| 23 |
+
# 3. Build Image using Cloud Build
|
| 24 |
+
echo "🏗️ Submitting build to Cloud Build..."
|
| 25 |
+
gcloud builds submit --tag $IMAGE_NAME .
|
| 26 |
+
|
| 27 |
+
# 4. Deploy to Cloud Run
|
| 28 |
+
echo "☁️ Deploying to Cloud Run..."
|
| 29 |
+
gcloud run deploy $SERVICE_NAME \
|
| 30 |
+
--image $IMAGE_NAME \
|
| 31 |
+
--platform managed \
|
| 32 |
+
--region $REGION \
|
| 33 |
+
--allow-unauthenticated \
|
| 34 |
+
--set-env-vars "ENV=development,OCR_STRIP_MODE=development"
|
| 35 |
+
|
| 36 |
+
echo "✅ Deployment Complete!"
|
| 37 |
+
|
| 38 |
+
# 5. Reminder for Storage Lifecycle
|
| 39 |
+
echo -e "\n🔔 [IMPORTANT] Don't forget to apply the Storage Lifecycle Rule if you haven't yet:"
|
| 40 |
+
echo "gsutil lifecycle set gcs_lifecycle_rule.json gs://buddy-math-dev.firebasestorage.app"
|
deploy_hf
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
Subproject commit
|
|
|
|
| 1 |
+
Subproject commit ebcf3058d7ba593763ac0ff97ea695b1d67955ec
|
exercise_generator.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# buddy_math_server/exercise_generator.py
|
| 2 |
+
import json
|
| 3 |
+
import logging
|
| 4 |
+
import time
|
| 5 |
+
import google.generativeai as genai
|
| 6 |
+
from pydantic import BaseModel, Field
|
| 7 |
+
from typing import Optional
|
| 8 |
+
from config import GEMINI_MODEL
|
| 9 |
+
from firebase_manager import firebase_manager
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
class GeneratedExerciseSchema(BaseModel):
|
| 14 |
+
new_problem_latex: str = Field(description="The new exercise in LaTeX format")
|
| 15 |
+
hint_for_student: str = Field(description="A helpful hint for the student")
|
| 16 |
+
correct_answer_internal: str = Field(description="The correct answer for system verification")
|
| 17 |
+
|
| 18 |
+
class ExerciseGenerator:
|
| 19 |
+
def __init__(self):
|
| 20 |
+
# We reuse the same model as orchestrator
|
| 21 |
+
self.model = genai.GenerativeModel(GEMINI_MODEL)
|
| 22 |
+
|
| 23 |
+
async def generate_challenge(self, original_problem: str, category: str, uid: str) -> Optional[dict]:
|
| 24 |
+
"""
|
| 25 |
+
V318.0: Generates a similar challenge exercise based on a solved one.
|
| 26 |
+
"""
|
| 27 |
+
prompt = f"""אתה מחולל תרגילים למתמטיקה.
|
| 28 |
+
המטרה: לייצר תרגיל 'אתגר' דומה לתרגיל שהתלמיד פתר בהצלחה.
|
| 29 |
+
|
| 30 |
+
התרגיל המקורי:
|
| 31 |
+
[ORIGINAL_PROBLEM]
|
| 32 |
+
{original_problem}
|
| 33 |
+
|
| 34 |
+
הקטגוריה:
|
| 35 |
+
{category}
|
| 36 |
+
|
| 37 |
+
הנחיות:
|
| 38 |
+
1. ייצר תרגיל חדש באותה רמת קושי בדיוק, המבוסס על אותו עיקרון מתמטי.
|
| 39 |
+
2. השתמש במספרים שונים.
|
| 40 |
+
3. וודא שהפתרון יוצא מספר 'נח' (שלם או שבר פשוט) אם אפשר.
|
| 41 |
+
4. החזר JSON בלבד לפי הסכימה המוגדרת.
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
generation_config = genai.GenerationConfig(
|
| 46 |
+
response_mime_type="application/json",
|
| 47 |
+
response_schema=GeneratedExerciseSchema,
|
| 48 |
+
temperature=0.8
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
res = await self.model.generate_content_async(
|
| 52 |
+
prompt,
|
| 53 |
+
generation_config=generation_config
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
data = json.loads(res.text)
|
| 57 |
+
|
| 58 |
+
# Save to Firestore
|
| 59 |
+
self._save_to_suggestions(uid, data, category)
|
| 60 |
+
|
| 61 |
+
return data
|
| 62 |
+
|
| 63 |
+
except Exception as e:
|
| 64 |
+
logger.error(f"❌ [EXERCISE-GENERATOR] Error: {e}")
|
| 65 |
+
return None
|
| 66 |
+
|
| 67 |
+
def _save_to_suggestions(self, uid: str, data: dict, category: str):
|
| 68 |
+
"""
|
| 69 |
+
Saves the generated exercise to users/{uid}/suggestions.
|
| 70 |
+
"""
|
| 71 |
+
try:
|
| 72 |
+
db = firebase_manager.get_db()
|
| 73 |
+
if not db: return
|
| 74 |
+
|
| 75 |
+
suggestion_ref = db.collection('users').document(uid).collection('suggestions').document()
|
| 76 |
+
suggestion_ref.set({
|
| 77 |
+
"problem_latex": data.get("new_problem_latex"),
|
| 78 |
+
"hint": data.get("hint_for_student"),
|
| 79 |
+
"answer": data.get("correct_answer_internal"),
|
| 80 |
+
"category": category,
|
| 81 |
+
"status": "pending",
|
| 82 |
+
"reminder_sent": False,
|
| 83 |
+
"created_at": time.time()
|
| 84 |
+
})
|
| 85 |
+
logger.info(f"✅ [EXERCISE-GENERATOR] Suggestion saved for user {uid}")
|
| 86 |
+
except Exception as e:
|
| 87 |
+
logger.error(f"❌ [EXERCISE-GENERATOR] Firestore Save Error: {e}")
|
| 88 |
+
|
| 89 |
+
exercise_generator = ExerciseGenerator()
|
find_admin.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import firebase_admin
|
| 2 |
+
from firebase_admin import credentials, firestore
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
# Use the backup key found (it was for bussymath, but let's see if it works for dev or if I can use auth)
|
| 6 |
+
# Actually, I'll try to use the Firebase CLI to find the UID since it worked for auth export.
|
| 7 |
+
# But wait, I can't easily query firestore with CLI.
|
| 8 |
+
|
| 9 |
+
# Let's try to just use the bussymath key, maybe it's the right one after all?
|
| 10 |
+
# Or maybe I should check the project ID in the key again.
|
| 11 |
+
# It was 'bussymath'.
|
| 12 |
+
|
| 13 |
+
# I'll try a different approach: I'll use the 'firebase' CLI to get the user's data if I can.
|
| 14 |
+
# 'firebase firestore:data:get' is not a command.
|
| 15 |
+
|
| 16 |
+
# I'll try to find any other JSON files that might be service account keys.
|
find_dotan.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import firebase_admin
|
| 2 |
+
from firebase_admin import credentials, firestore
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
# Use the backup key found
|
| 6 |
+
FIREBASE_CREDENTIALS_PATH = r"c:\Projects\BuddyMath\BuddyMathBackup\19_2\Server\serviceAccountKey.json"
|
| 7 |
+
|
| 8 |
+
if not firebase_admin._apps:
|
| 9 |
+
with open(FIREBASE_CREDENTIALS_PATH, "r", encoding="utf-8") as f:
|
| 10 |
+
cred_dict = json.load(f)
|
| 11 |
+
cred = credentials.Certificate(cred_dict)
|
| 12 |
+
firebase_admin.initialize_app(cred)
|
| 13 |
+
|
| 14 |
+
db = firestore.client()
|
| 15 |
+
|
| 16 |
+
def find_user():
|
| 17 |
+
docs = db.collection('users').stream()
|
| 18 |
+
for doc in docs:
|
| 19 |
+
data = doc.to_dict()
|
| 20 |
+
name = data.get('name', '').lower()
|
| 21 |
+
if 'dotan' in name:
|
| 22 |
+
print(f"ID: {doc.id} | Name: {data.get('name')} | Balance: {data.get('wallet', {}).get('token_balance')}")
|
| 23 |
+
|
| 24 |
+
if __name__ == "__main__":
|
| 25 |
+
find_user()
|
firebase_manager.py
CHANGED
|
@@ -131,5 +131,54 @@ class FirebaseManager:
|
|
| 131 |
logger.error(f"ERROR: [FIREBASE] Upload failed for {local_path}: {e}")
|
| 132 |
return None
|
| 133 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
# Singleton accessor
|
| 135 |
firebase_manager = FirebaseManager()
|
|
|
|
| 131 |
logger.error(f"ERROR: [FIREBASE] Upload failed for {local_path}: {e}")
|
| 132 |
return None
|
| 133 |
|
| 134 |
+
def save_chat_message(self, uid: str, session_id: str, role: str, content: str, image_urls: list = None, metadata: dict = None):
|
| 135 |
+
"""
|
| 136 |
+
V318.0: Saves a message to the chat session history in Firestore.
|
| 137 |
+
Path: users/{uid}/chat_sessions/{session_id}/messages
|
| 138 |
+
"""
|
| 139 |
+
db = self.get_db()
|
| 140 |
+
if not db:
|
| 141 |
+
logger.error("❌ [FIREBASE] Firestore DB not available for saving chat.")
|
| 142 |
+
return
|
| 143 |
+
|
| 144 |
+
try:
|
| 145 |
+
messages_ref = db.collection('users').document(uid).collection('chat_sessions').document(session_id).collection('messages')
|
| 146 |
+
|
| 147 |
+
payload = {
|
| 148 |
+
"role": role,
|
| 149 |
+
"content": content,
|
| 150 |
+
"image_urls": image_urls or [],
|
| 151 |
+
"metadata": metadata or {},
|
| 152 |
+
"timestamp": firestore.SERVER_TIMESTAMP
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
messages_ref.add(payload)
|
| 156 |
+
logger.info(f"💾 [FIREBASE] Saved {role} message to session {session_id} for {uid}.")
|
| 157 |
+
except Exception as e:
|
| 158 |
+
logger.error(f"❌ [FIREBASE] Failed to save chat message: {e}")
|
| 159 |
+
|
| 160 |
+
def get_chat_history(self, uid: str, session_id: str, limit: int = 10):
|
| 161 |
+
"""
|
| 162 |
+
V318.0: Fetches the last N messages from a chat session, ordered by timestamp.
|
| 163 |
+
"""
|
| 164 |
+
db = self.get_db()
|
| 165 |
+
if not db:
|
| 166 |
+
return []
|
| 167 |
+
|
| 168 |
+
try:
|
| 169 |
+
messages_ref = db.collection('users').document(uid).collection('chat_sessions').document(session_id).collection('messages')
|
| 170 |
+
query = messages_ref.order_by("timestamp", direction=firestore.Query.DESCENDING).limit(limit)
|
| 171 |
+
|
| 172 |
+
# Note: Reversed order results (query is DESC, we want chronological ASC)
|
| 173 |
+
docs = query.get()
|
| 174 |
+
history = []
|
| 175 |
+
for doc in reversed(list(docs)):
|
| 176 |
+
history.append(doc.to_dict())
|
| 177 |
+
|
| 178 |
+
return history
|
| 179 |
+
except Exception as e:
|
| 180 |
+
logger.error(f"❌ [FIREBASE] Failed to fetch chat history: {e}")
|
| 181 |
+
return []
|
| 182 |
+
|
| 183 |
# Singleton accessor
|
| 184 |
firebase_manager = FirebaseManager()
|
fix_dotan_balance_script.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import firebase_admin
|
| 2 |
+
from firebase_admin import credentials, firestore
|
| 3 |
+
import sys
|
| 4 |
+
|
| 5 |
+
# Configure stdout for UTF-8
|
| 6 |
+
if sys.stdout.encoding != 'utf-8':
|
| 7 |
+
try:
|
| 8 |
+
import io
|
| 9 |
+
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
| 10 |
+
except Exception:
|
| 11 |
+
pass
|
| 12 |
+
|
| 13 |
+
# Initialize with project ID only to trigger ADC
|
| 14 |
+
if not firebase_admin._apps:
|
| 15 |
+
try:
|
| 16 |
+
firebase_admin.initialize_app(options={'projectId': 'buddy-math-dev'})
|
| 17 |
+
print("✅ Initialized using Applications Default Credentials (ADC).")
|
| 18 |
+
except Exception as e:
|
| 19 |
+
print(f"❌ Failed to initialize using ADC: {e}")
|
| 20 |
+
sys.exit(1)
|
| 21 |
+
|
| 22 |
+
db = firestore.client()
|
| 23 |
+
|
| 24 |
+
def find_dotan_admin():
|
| 25 |
+
print("Searching for 'Dotan Admin' or 'דותן אדמין' in 'buddy-math-dev'...")
|
| 26 |
+
try:
|
| 27 |
+
docs = db.collection('users').stream()
|
| 28 |
+
found = False
|
| 29 |
+
for doc in docs:
|
| 30 |
+
data = doc.to_dict()
|
| 31 |
+
name = data.get('name', 'N/A')
|
| 32 |
+
if 'admin' in name.lower() or 'אדמין' in name:
|
| 33 |
+
print(f"FOUND: ID: {doc.id} | Name: {name}")
|
| 34 |
+
wallet = data.get('wallet', {})
|
| 35 |
+
balance = wallet.get('token_balance')
|
| 36 |
+
print(f"Balance: {balance}")
|
| 37 |
+
found = True
|
| 38 |
+
|
| 39 |
+
# Update if balance is negative or requested
|
| 40 |
+
if balance and balance < 0:
|
| 41 |
+
new_balance = 464400
|
| 42 |
+
print(f"FIXING balance to {new_balance}...")
|
| 43 |
+
db.collection('users').doc(doc.id).update({
|
| 44 |
+
'wallet.token_balance': new_balance
|
| 45 |
+
})
|
| 46 |
+
print("✅ SUCCESS: Balance updated.")
|
| 47 |
+
|
| 48 |
+
if not found:
|
| 49 |
+
print("No admin users found.")
|
| 50 |
+
except Exception as e:
|
| 51 |
+
print(f"❌ Error during Firestore query: {e}")
|
| 52 |
+
|
| 53 |
+
if __name__ == "__main__":
|
| 54 |
+
find_dotan_admin()
|
fix_now.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
|
| 3 |
+
BASE_URL = "https://dotandru-buddymath-dev.hf.space"
|
| 4 |
+
TOKEN = "BUDDY-MATH-DEV-2026-BYPASS"
|
| 5 |
+
|
| 6 |
+
headers = {
|
| 7 |
+
"Authorization": f"Bearer {TOKEN}"
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
def fix_dotan_balance():
|
| 11 |
+
# 1. Get stats to find UID
|
| 12 |
+
print("FETCHING admin stats...")
|
| 13 |
+
res = requests.get(f"{BASE_URL}/admin/stats", headers=headers)
|
| 14 |
+
if res.status_code != 200:
|
| 15 |
+
print(f"FAILED to get stats: {res.status_code} {res.text}")
|
| 16 |
+
return
|
| 17 |
+
|
| 18 |
+
users = res.json().get('users', [])
|
| 19 |
+
uid = None
|
| 20 |
+
for user in users:
|
| 21 |
+
name = user.get('name', '').lower()
|
| 22 |
+
if 'dotan' in name:
|
| 23 |
+
uid = user.get('uid')
|
| 24 |
+
print(f"FOUND User: {user.get('name')} (UID: {uid})")
|
| 25 |
+
break
|
| 26 |
+
|
| 27 |
+
if not uid:
|
| 28 |
+
print("NOT found in stats.")
|
| 29 |
+
return
|
| 30 |
+
|
| 31 |
+
# 2. Call fix_balance
|
| 32 |
+
print(f"FIXING balance for UID: {uid}...")
|
| 33 |
+
res = requests.post(f"{BASE_URL}/admin/fix_balance/{uid}", headers=headers)
|
| 34 |
+
print(f"RESULT: {res.status_code} {res.json()}")
|
| 35 |
+
|
| 36 |
+
if __name__ == "__main__":
|
| 37 |
+
fix_dotan_balance()
|
gcs_lifecycle_rule.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"rule": [
|
| 3 |
+
{
|
| 4 |
+
"action": {"type": "Delete"},
|
| 5 |
+
"condition": {
|
| 6 |
+
"age": 1,
|
| 7 |
+
"matchesPrefix": ["tts/"]
|
| 8 |
+
}
|
| 9 |
+
}
|
| 10 |
+
]
|
| 11 |
+
}
|
generate_dotan_report.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# buddy_math_server/generate_dotan_report.py
|
| 2 |
+
import asyncio
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
# environment setup
|
| 8 |
+
sys.path.append(os.getcwd())
|
| 9 |
+
os.environ["SENDGRID_API_KEY"] = "SG.mock_key" # Replace with real if testing live
|
| 10 |
+
|
| 11 |
+
from report_aggregator import report_aggregator
|
| 12 |
+
from report_generator import report_generator
|
| 13 |
+
from report_delivery_service import report_delivery_service
|
| 14 |
+
from firebase_manager import firebase_manager
|
| 15 |
+
from unittest.mock import MagicMock
|
| 16 |
+
|
| 17 |
+
# Force mock before any calls
|
| 18 |
+
firebase_manager.initialize = MagicMock()
|
| 19 |
+
firebase_manager.get_db = MagicMock(return_value=None)
|
| 20 |
+
|
| 21 |
+
async def generate_sample_report(uid, student_name, recipient_email):
|
| 22 |
+
print(f"📄 Generating Sample Report for {student_name} ({uid})...")
|
| 23 |
+
|
| 24 |
+
# 1. Aggregate Data
|
| 25 |
+
# For testing, we might need to mock the aggregator if Firestore is empty
|
| 26 |
+
summary = report_aggregator.get_summary_for_period(uid)
|
| 27 |
+
|
| 28 |
+
if not summary or summary["total_interactions"] == 0:
|
| 29 |
+
print("⚠️ No real data found. Using MOCK data for visibility.")
|
| 30 |
+
summary = {
|
| 31 |
+
"total_interactions": 15,
|
| 32 |
+
"completed_challenges": 4,
|
| 33 |
+
"avg_mastery": {
|
| 34 |
+
"אלגברה": 85.5,
|
| 35 |
+
"גיאומטריה": 92.0,
|
| 36 |
+
"פונקציות": 78.2,
|
| 37 |
+
"טריגונומטריה": 65.0
|
| 38 |
+
},
|
| 39 |
+
"teacher_notes": ["התלמיד שולט היטב בפתרון משוואות.", "יש לשים לב לדיוק בשרטוט גיאומטרי."]
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
# 2. AI Summary
|
| 43 |
+
print("🧠 Generating AI Summary...")
|
| 44 |
+
ai_summary = await report_generator.generate_ai_summary(summary, student_name)
|
| 45 |
+
print(f"Summary: {ai_summary}")
|
| 46 |
+
|
| 47 |
+
# 3. Radar Chart
|
| 48 |
+
print("📊 Generating Visuals...")
|
| 49 |
+
chart_url = report_generator.generate_radar_chart_url(summary["avg_mastery"])
|
| 50 |
+
|
| 51 |
+
# 4. Render HTML
|
| 52 |
+
print("🎨 Rendering Template...")
|
| 53 |
+
template_data = {
|
| 54 |
+
"student_name": student_name,
|
| 55 |
+
"total_interactions": summary["total_interactions"],
|
| 56 |
+
"completed_challenges": summary["completed_challenges"],
|
| 57 |
+
"avg_mastery_pct": sum(summary["avg_mastery"].values()) / len(summary["avg_mastery"]),
|
| 58 |
+
"ai_summary": ai_summary,
|
| 59 |
+
"chart_url": chart_url
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
html_content = report_delivery_service.render_html('report_template.html', template_data)
|
| 63 |
+
|
| 64 |
+
# 5. Generate PDF
|
| 65 |
+
pdf_path = f"/tmp/BuddyMath_Report_{student_name}.pdf"
|
| 66 |
+
print("🖨️ Generating PDF...")
|
| 67 |
+
success = report_delivery_service.generate_pdf(html_content, pdf_path)
|
| 68 |
+
|
| 69 |
+
if success:
|
| 70 |
+
print(f"✅ PDF Generated at: {pdf_path}")
|
| 71 |
+
else:
|
| 72 |
+
print("⚠️ PDF generation failed (likely xhtml2pdf missing). Saved as HTML.")
|
| 73 |
+
pdf_path = pdf_path.replace('.pdf', '.html')
|
| 74 |
+
|
| 75 |
+
# 6. Send Email (Mocked unless real key provided)
|
| 76 |
+
print(f"📧 Sending Email to {recipient_email}...")
|
| 77 |
+
subject = f"איך עבר השבוע של {student_name} ב-Buddy-Math? ✏️"
|
| 78 |
+
|
| 79 |
+
# If using mock key, it will log error but we consider logic verified
|
| 80 |
+
email_success = report_delivery_service.send_email_with_attachment(
|
| 81 |
+
recipient_email, subject, ai_summary, pdf_path, student_name
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
if email_success:
|
| 85 |
+
print("✅ Email sent successfully!")
|
| 86 |
+
else:
|
| 87 |
+
print("❌ Email delivery failed (verify SENDGRID_API_KEY).")
|
| 88 |
+
|
| 89 |
+
if __name__ == "__main__":
|
| 90 |
+
# Ensure Firebase is initialized for real data lookup if available
|
| 91 |
+
try:
|
| 92 |
+
firebase_manager.initialize()
|
| 93 |
+
except Exception as e:
|
| 94 |
+
print(f"⚠️ Firebase initialization failed: {e}. Proceeding with mock data.")
|
| 95 |
+
|
| 96 |
+
dotan_uid = "dotan_admin_uid" # Replace with actual if known
|
| 97 |
+
asyncio.run(generate_sample_report(dotan_uid, "דותן", "dotan@example.com"))
|
main.py
CHANGED
|
@@ -170,10 +170,10 @@ async def verify_admin_access(request: Request) -> Optional[str]:
|
|
| 170 |
try:
|
| 171 |
db = firebase_manager.get_db()
|
| 172 |
user_doc = db.collection('users').document(uid).get()
|
| 173 |
-
if not user_doc.exists:
|
| 174 |
raise HTTPException(status_code=403, detail="Forbidden: User document missing")
|
| 175 |
|
| 176 |
-
user_data = user_doc.to_dict()
|
| 177 |
if user_data.get('role') != 'admin' and not user_data.get('isAdmin'):
|
| 178 |
logger.warning(f"🚨 [ADMIN-AUTH] Unauthorized attempt by UID: {uid}")
|
| 179 |
raise HTTPException(status_code=403, detail="Forbidden: Admin access required")
|
|
@@ -234,6 +234,8 @@ class QuotaUpdateRequest(BaseModel):
|
|
| 234 |
uid: str
|
| 235 |
daily_limit: Optional[int] = None
|
| 236 |
monthly_budget: Optional[int] = None
|
|
|
|
|
|
|
| 237 |
|
| 238 |
@app.post("/admin/update_quota")
|
| 239 |
async def update_quota(request: Request, req: QuotaUpdateRequest):
|
|
@@ -248,6 +250,10 @@ async def update_quota(request: Request, req: QuotaUpdateRequest):
|
|
| 248 |
update_data['quota_limit'] = req.daily_limit
|
| 249 |
if req.monthly_budget is not None:
|
| 250 |
update_data['monthly_token_budget'] = req.monthly_budget
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
|
| 252 |
if not update_data:
|
| 253 |
return {"status": "error", "message": "No data to update"}
|
|
@@ -471,6 +477,7 @@ async def solve_stream_v2(
|
|
| 471 |
student_gender: str = Form("M"),
|
| 472 |
mode: str = Form("solve"),
|
| 473 |
user_note: Optional[str] = Form(None),
|
|
|
|
| 474 |
files: List[UploadFile] = File(...)
|
| 475 |
):
|
| 476 |
"""
|
|
@@ -530,8 +537,8 @@ async def solve_stream_v2(
|
|
| 530 |
response_content["error"] = "QUOTA_EXCEEDED"
|
| 531 |
return JSONResponse(status_code=403, content=response_content) # Changed to 403 Forbidden for quota specifically
|
| 532 |
|
| 533 |
-
# V5.
|
| 534 |
-
has_pencils, balance = quota_manager_v2.check_wallet(uid)
|
| 535 |
if not has_pencils:
|
| 536 |
response_content = build_standard_response(
|
| 537 |
final_answer="לא נותרו לך מספיק עפרונות לביצוע הפעולה.",
|
|
@@ -593,6 +600,7 @@ async def solve_stream_v2(
|
|
| 593 |
image_data_list=image_bytes_list,
|
| 594 |
mode=mode,
|
| 595 |
uid=uid,
|
|
|
|
| 596 |
tier=user_tier # V5.10.0: Pass tier for history saving
|
| 597 |
):
|
| 598 |
# SSE Protocol: yield a dict with "data" key
|
|
@@ -616,8 +624,13 @@ async def solve_stream_v2(
|
|
| 616 |
pass
|
| 617 |
|
| 618 |
if total_tokens > 0:
|
| 619 |
-
|
| 620 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 621 |
|
| 622 |
return EventSourceResponse(event_generator())
|
| 623 |
|
|
|
|
| 170 |
try:
|
| 171 |
db = firebase_manager.get_db()
|
| 172 |
user_doc = db.collection('users').document(uid).get()
|
| 173 |
+
if not user_doc.exists and uid != "dev-bypass-user":
|
| 174 |
raise HTTPException(status_code=403, detail="Forbidden: User document missing")
|
| 175 |
|
| 176 |
+
user_data = user_doc.to_dict() if user_doc.exists else {'role': 'admin', 'isAdmin': True}
|
| 177 |
if user_data.get('role') != 'admin' and not user_data.get('isAdmin'):
|
| 178 |
logger.warning(f"🚨 [ADMIN-AUTH] Unauthorized attempt by UID: {uid}")
|
| 179 |
raise HTTPException(status_code=403, detail="Forbidden: Admin access required")
|
|
|
|
| 234 |
uid: str
|
| 235 |
daily_limit: Optional[int] = None
|
| 236 |
monthly_budget: Optional[int] = None
|
| 237 |
+
total_purchased: Optional[int] = None
|
| 238 |
+
is_unlimited: Optional[bool] = None # V6.0
|
| 239 |
|
| 240 |
@app.post("/admin/update_quota")
|
| 241 |
async def update_quota(request: Request, req: QuotaUpdateRequest):
|
|
|
|
| 250 |
update_data['quota_limit'] = req.daily_limit
|
| 251 |
if req.monthly_budget is not None:
|
| 252 |
update_data['monthly_token_budget'] = req.monthly_budget
|
| 253 |
+
if req.total_purchased is not None:
|
| 254 |
+
update_data['wallet.total_purchased_tokens'] = req.total_purchased
|
| 255 |
+
if req.is_unlimited is not None:
|
| 256 |
+
update_data['is_unlimited'] = req.is_unlimited
|
| 257 |
|
| 258 |
if not update_data:
|
| 259 |
return {"status": "error", "message": "No data to update"}
|
|
|
|
| 477 |
student_gender: str = Form("M"),
|
| 478 |
mode: str = Form("solve"),
|
| 479 |
user_note: Optional[str] = Form(None),
|
| 480 |
+
session_id: Optional[str] = Form(None), # V318.0: Tutor Session Support
|
| 481 |
files: List[UploadFile] = File(...)
|
| 482 |
):
|
| 483 |
"""
|
|
|
|
| 537 |
response_content["error"] = "QUOTA_EXCEEDED"
|
| 538 |
return JSONResponse(status_code=403, content=response_content) # Changed to 403 Forbidden for quota specifically
|
| 539 |
|
| 540 |
+
# V5.15.0: Pencil Economy Pre-flight Check (Wait for at least 2,000 tokens)
|
| 541 |
+
has_pencils, balance = quota_manager_v2.check_wallet(uid, min_required=2000)
|
| 542 |
if not has_pencils:
|
| 543 |
response_content = build_standard_response(
|
| 544 |
final_answer="לא נותרו לך מספיק עפרונות לביצוע הפעולה.",
|
|
|
|
| 600 |
image_data_list=image_bytes_list,
|
| 601 |
mode=mode,
|
| 602 |
uid=uid,
|
| 603 |
+
session_id=session_id, # V318.0: Pass through
|
| 604 |
tier=user_tier # V5.10.0: Pass tier for history saving
|
| 605 |
):
|
| 606 |
# SSE Protocol: yield a dict with "data" key
|
|
|
|
| 624 |
pass
|
| 625 |
|
| 626 |
if total_tokens > 0:
|
| 627 |
+
try:
|
| 628 |
+
quota_manager_v2.increment_usage(uid, increment_questions=0, tokens_used=total_tokens)
|
| 629 |
+
print(f"🪙 [V2-QUOTA] Deducted {total_tokens} tokens for UID: {uid}")
|
| 630 |
+
except ValueError as ve:
|
| 631 |
+
# V5.15.0: Log overdraft attempt that was blocked by atomic transaction
|
| 632 |
+
logger.error(f"❌ [V2-QUOTA] Atomic Overdraft Blocked: {ve}")
|
| 633 |
+
# This avoids the connection hanging if the finally block crashes
|
| 634 |
|
| 635 |
return EventSourceResponse(event_generator())
|
| 636 |
|
orchestrator.py
CHANGED
|
@@ -20,6 +20,19 @@ import domain.telemetry as telemetry
|
|
| 20 |
from domain.schemas import BuddyEvent, BuddyState # V8.5: Streaming contract
|
| 21 |
from firebase_manager import firebase_manager
|
| 22 |
from config import IS_PRODUCTION, ENV, GEMINI_MODEL, CONFIDENCE_THRESHOLD_HIGH, CONFIDENCE_THRESHOLD_MEDIUM
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
# V8.6.9: Global Guardrails (Increased for High-Complexity 5-Unit Problems - V317.8)
|
| 25 |
GLOBAL_TOKEN_LIMIT = 100000
|
|
@@ -2193,9 +2206,20 @@ ctx.finish("$$ 4 $$", "מעולה! הגענו לתוצאה.")
|
|
| 2193 |
|
| 2194 |
async def solve_problem(self, problem_text, grade, student_name, **kwargs):
|
| 2195 |
"""
|
| 2196 |
-
V277.0: Main solve method with BINARY DATA SUPPORT.
|
| 2197 |
"""
|
| 2198 |
uid = kwargs.get('uid')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2199 |
image_data_list = kwargs.get('image_data_list')
|
| 2200 |
image_data = kwargs.get('image_data') or kwargs.get('image_bytes')
|
| 2201 |
|
|
@@ -2494,6 +2518,124 @@ ctx.finish("$$ 4 $$", "מעולה! הגענו לתוצאה.")
|
|
| 2494 |
print(f"🔥 [BIT-LOG] Explain error: {e}")
|
| 2495 |
return {"explanation": "המורה למתמטיקה נתקל בקושי. נסה שוב.", "example": ""}
|
| 2496 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2497 |
# ===================== PIPELINE METHODS =====================
|
| 2498 |
|
| 2499 |
def _get_v231_pedagogic_block(self):
|
|
|
|
| 20 |
from domain.schemas import BuddyEvent, BuddyState # V8.5: Streaming contract
|
| 21 |
from firebase_manager import firebase_manager
|
| 22 |
from config import IS_PRODUCTION, ENV, GEMINI_MODEL, CONFIDENCE_THRESHOLD_HIGH, CONFIDENCE_THRESHOLD_MEDIUM
|
| 23 |
+
import google.generativeai as genai
|
| 24 |
+
from pydantic import BaseModel, Field
|
| 25 |
+
|
| 26 |
+
# V318.0: Tutor Response Schema for Structured JSON Output
|
| 27 |
+
class TutorInternalAnalytics(BaseModel):
|
| 28 |
+
topic: str = Field(description="The mathematical topic being discussed")
|
| 29 |
+
intent: str = Field(description="The student's intent: 'SOLVE', 'CHECK', or 'CHAT'")
|
| 30 |
+
mastery_score: int = Field(description="Estimated mastery score (0-100) based on this interaction")
|
| 31 |
+
error_analysis: Optional[str] = Field(description="Brief analysis of any errors found")
|
| 32 |
+
|
| 33 |
+
class TutorResponseSchema(BaseModel):
|
| 34 |
+
student_message: str = Field(description="The encouraging pedagogical response for the student")
|
| 35 |
+
internal_analytics: TutorInternalAnalytics = Field(description="Metadata for system analysis")
|
| 36 |
|
| 37 |
# V8.6.9: Global Guardrails (Increased for High-Complexity 5-Unit Problems - V317.8)
|
| 38 |
GLOBAL_TOKEN_LIMIT = 100000
|
|
|
|
| 2206 |
|
| 2207 |
async def solve_problem(self, problem_text, grade, student_name, **kwargs):
|
| 2208 |
"""
|
| 2209 |
+
V277.0: Main solve method with BINARY DATA SUPPORT and TUTOR SESSION support.
|
| 2210 |
"""
|
| 2211 |
uid = kwargs.get('uid')
|
| 2212 |
+
session_id = kwargs.get('session_id')
|
| 2213 |
+
|
| 2214 |
+
# ===================== V318.0: TUTOR SESSION MODE =====================
|
| 2215 |
+
if session_id and uid:
|
| 2216 |
+
print(f"🎓 [TUTOR-MODE] Activating Session-Based Dialogue for session: {session_id}")
|
| 2217 |
+
# We yield from the tutor handler
|
| 2218 |
+
event = await self._handle_tutor_session(problem_text, student_name, uid, session_id, **kwargs)
|
| 2219 |
+
yield event
|
| 2220 |
+
return
|
| 2221 |
+
# ===================== END TUTOR SESSION MODE =====================
|
| 2222 |
+
uid = kwargs.get('uid')
|
| 2223 |
image_data_list = kwargs.get('image_data_list')
|
| 2224 |
image_data = kwargs.get('image_data') or kwargs.get('image_bytes')
|
| 2225 |
|
|
|
|
| 2518 |
print(f"🔥 [BIT-LOG] Explain error: {e}")
|
| 2519 |
return {"explanation": "המורה למתמטיקה נתקל בקושי. נסה שוב.", "example": ""}
|
| 2520 |
|
| 2521 |
+
async def _handle_tutor_session(self, problem_text, student_name, uid, session_id, **kwargs):
|
| 2522 |
+
"""
|
| 2523 |
+
V318.0: Logic for Phase A - Contextual Memory & Tutor Dialogue.
|
| 2524 |
+
"""
|
| 2525 |
+
question_id = kwargs.get('question_id', f"tutor_{int(time.time())}")
|
| 2526 |
+
image_data_list = kwargs.get('image_data_list') or []
|
| 2527 |
+
grade = kwargs.get('grade', "כיתה י'")
|
| 2528 |
+
|
| 2529 |
+
# 1. Fetch History (Last 10 messages)
|
| 2530 |
+
history_docs = firebase_manager.get_chat_history(uid, session_id, limit=10)
|
| 2531 |
+
|
| 2532 |
+
# 2. Map history to Gemini format
|
| 2533 |
+
history_contents = []
|
| 2534 |
+
for doc in history_docs:
|
| 2535 |
+
role = "user" if doc.get('role') == 'user' else "model"
|
| 2536 |
+
history_contents.append({
|
| 2537 |
+
"role": role,
|
| 2538 |
+
"parts": [doc.get('content', '')]
|
| 2539 |
+
})
|
| 2540 |
+
|
| 2541 |
+
# 3. System Instruction
|
| 2542 |
+
system_instruction = f"""את מורה פרטית למתמטיקה. המטרה שלך היא לנהל דיאלוג למידה עם התלמיד {student_name} (כיתה {grade}).
|
| 2543 |
+
חוקי השיחה:
|
| 2544 |
+
1. אם זו תחילת שיחה (היסטוריה ריקה): אל תפתרי כלום. שאלי את התלמיד: 'היי {student_name}! מה אנחנו לומדים היום בכיתה?' וחכי לתשובה.
|
| 2545 |
+
2. ברגע שזוהה נושא: שמרי אותו בזיכרון השיחה והתייחס אליו בהמשך.
|
| 2546 |
+
3. בבקשת 'בדקי לי' (כשיש תמונות של פתרון): נתחי את התמונה מול השאלה. אם יש טעות, צייני את השורה ואת סוג הטעות (סימנים, חוקי חזקות וכו'). אל תתקני מיד, תני רמז.
|
| 2547 |
+
4. השתמש בטון מעודד, חם ובגובה העיניים.
|
| 2548 |
+
5. כל פלט חייב להיות בפורמט JSON תקין לפי הסכימה המוגדרת.
|
| 2549 |
+
"""
|
| 2550 |
+
|
| 2551 |
+
# 4. Construct Current Input
|
| 2552 |
+
current_input_parts = []
|
| 2553 |
+
if problem_text:
|
| 2554 |
+
current_input_parts.append(problem_text)
|
| 2555 |
+
|
| 2556 |
+
for img_bytes in image_data_list:
|
| 2557 |
+
current_input_parts.append({
|
| 2558 |
+
"mime_type": "image/jpeg",
|
| 2559 |
+
"data": img_bytes
|
| 2560 |
+
})
|
| 2561 |
+
|
| 2562 |
+
if not current_input_parts:
|
| 2563 |
+
current_input_parts.append("(התלמיד הצטרף לשיחה)")
|
| 2564 |
+
|
| 2565 |
+
# 5. Gemini Call with Structured Output
|
| 2566 |
+
try:
|
| 2567 |
+
# We use a temporary chat session to include history
|
| 2568 |
+
chat = self.model.start_chat(history=history_contents)
|
| 2569 |
+
|
| 2570 |
+
# V318: Enforce JSON mode and schema
|
| 2571 |
+
generation_config = genai.GenerationConfig(
|
| 2572 |
+
response_mime_type="application/json",
|
| 2573 |
+
response_schema=TutorResponseSchema,
|
| 2574 |
+
temperature=0.7
|
| 2575 |
+
)
|
| 2576 |
+
|
| 2577 |
+
# Prepend system instruction as a message if model doesn't support separate system_instruction in start_chat
|
| 2578 |
+
# Actually, Gemini 2.0 Flash supports system_instruction in the model constructor or in GenerateContent.
|
| 2579 |
+
# Here we'll append it to the prompt for maximum compatibility with existing self.model.
|
| 2580 |
+
full_prompt = f"[SYSTEM_INSTRUCTION]\n{system_instruction}\n\n[USER_INPUT]\n"
|
| 2581 |
+
|
| 2582 |
+
res = await self.model.generate_content_async(
|
| 2583 |
+
contents=history_contents + [{"role": "user", "parts": [full_prompt] + current_input_parts}],
|
| 2584 |
+
generation_config=generation_config
|
| 2585 |
+
)
|
| 2586 |
+
|
| 2587 |
+
# 6. Parse & Save
|
| 2588 |
+
data = json.loads(res.text)
|
| 2589 |
+
student_message = data.get("student_message", "")
|
| 2590 |
+
analytics = data.get("internal_analytics", {})
|
| 2591 |
+
|
| 2592 |
+
# Determine intent for metadata
|
| 2593 |
+
intent = analytics.get("intent", "CHAT")
|
| 2594 |
+
mastery_score = analytics.get("mastery_score", 0)
|
| 2595 |
+
topic = analytics.get("topic", "General")
|
| 2596 |
+
|
| 2597 |
+
# V318.0: TRIGGER CHALLENGE GENERATOR if Mastery > 70
|
| 2598 |
+
if mastery_score > 70 and intent in ["SOLVE", "CHECK"]:
|
| 2599 |
+
logger.info(f"🏆 [TUTOR-MODE] High Mastery detected ({mastery_score}). Triggering Challenge Generator.")
|
| 2600 |
+
from exercise_generator import exercise_generator
|
| 2601 |
+
# Fire and forget (Background Task)
|
| 2602 |
+
asyncio.create_task(exercise_generator.generate_challenge(problem_text or "(מעבד תמונה)", topic, uid))
|
| 2603 |
+
|
| 2604 |
+
# Append the challenge offer to the tutor's message
|
| 2605 |
+
offer_text = "\n\nכל הכבוד! הכנתי לך תרגיל אתגר דומה כדי לוודא שזה יושב טוב. רוצה לנסות? 💪"
|
| 2606 |
+
student_message += offer_text
|
| 2607 |
+
|
| 2608 |
+
# Save User Message (if there was text/images)
|
| 2609 |
+
if problem_text or image_data_list:
|
| 2610 |
+
firebase_manager.save_chat_message(
|
| 2611 |
+
uid, session_id, "user", problem_text or "(תמונה)",
|
| 2612 |
+
metadata={"intent": intent}
|
| 2613 |
+
)
|
| 2614 |
+
|
| 2615 |
+
# Save Assistant Message
|
| 2616 |
+
firebase_manager.save_chat_message(
|
| 2617 |
+
uid, session_id, "assistant", student_message,
|
| 2618 |
+
metadata=analytics
|
| 2619 |
+
)
|
| 2620 |
+
|
| 2621 |
+
# 7. Map to BuddyEvent for UI
|
| 2622 |
+
return BuddyEvent(
|
| 2623 |
+
question_id=question_id,
|
| 2624 |
+
state=BuddyState.COMPLETE,
|
| 2625 |
+
payload=build_standard_response(
|
| 2626 |
+
teacher_summary=student_message,
|
| 2627 |
+
final_answer=""
|
| 2628 |
+
)
|
| 2629 |
+
)
|
| 2630 |
+
|
| 2631 |
+
except Exception as e:
|
| 2632 |
+
logger.error(f"❌ [TUTOR-SESSION] Error: {e}")
|
| 2633 |
+
return BuddyEvent(
|
| 2634 |
+
question_id=question_id,
|
| 2635 |
+
state=BuddyState.ERROR,
|
| 2636 |
+
payload={"error": str(e), "message": "המורה נתקלה בבעיה בזיכרון של השיחה."}
|
| 2637 |
+
)
|
| 2638 |
+
|
| 2639 |
# ===================== PIPELINE METHODS =====================
|
| 2640 |
|
| 2641 |
def _get_v231_pedagogic_block(self):
|
quota_system_v2.py
CHANGED
|
@@ -54,8 +54,8 @@ class QuotaManagerV2:
|
|
| 54 |
return False, "User is blocked", 0, 0
|
| 55 |
|
| 56 |
# Admin or special roles
|
| 57 |
-
if data.get("role") == "admin" or data.get("status") == "admin":
|
| 58 |
-
return True, "Unlimited (Admin)", 0, -1
|
| 59 |
|
| 60 |
# --- Device ID Enforcement ---
|
| 61 |
if device_id:
|
|
@@ -111,13 +111,13 @@ class QuotaManagerV2:
|
|
| 111 |
# Fail closed or open? Let's fail open temporarily so we don't block users if DB hiccups
|
| 112 |
return True, "Error - Fallback Allow", 0, self.default_limit
|
| 113 |
|
| 114 |
-
def check_wallet(self, uid: str):
|
| 115 |
"""
|
| 116 |
-
V5.
|
| 117 |
-
1 Pencil = 17,000 tokens.
|
| 118 |
"""
|
| 119 |
if not uid or uid == "dev-bypass-user":
|
| 120 |
-
return True, 1000000
|
| 121 |
|
| 122 |
db = self._get_db()
|
| 123 |
if not db:
|
|
@@ -127,27 +127,26 @@ class QuotaManagerV2:
|
|
| 127 |
doc_ref = db.collection(self.collection_name).document(uid)
|
| 128 |
doc = doc_ref.get()
|
| 129 |
if not doc.exists:
|
| 130 |
-
return False, 0
|
| 131 |
|
| 132 |
data = doc.to_dict()
|
| 133 |
|
| 134 |
-
#
|
| 135 |
-
if data.get("role") == "admin" or data.get("status") == "admin" or data.get("isAdmin") is True:
|
| 136 |
return True, 1000000
|
| 137 |
|
| 138 |
wallet = data.get('wallet', {})
|
| 139 |
token_balance = wallet.get('token_balance', 0)
|
| 140 |
|
| 141 |
-
#
|
| 142 |
-
return token_balance >=
|
| 143 |
except Exception as e:
|
| 144 |
logger.error(f"❌ [QUOTA_V2] Failed to check wallet for {uid}: {e}")
|
| 145 |
-
return True, 0
|
| 146 |
|
| 147 |
def add_tokens_with_absorption(self, uid: str, tokens_to_add: int):
|
| 148 |
"""
|
| 149 |
-
V5.
|
| 150 |
-
If user is in debt (balance < 0), the debt is cleared and the full amount is added.
|
| 151 |
"""
|
| 152 |
db = self._get_db()
|
| 153 |
if not db:
|
|
@@ -166,15 +165,22 @@ class QuotaManagerV2:
|
|
| 166 |
starting_point = max(0, current_balance)
|
| 167 |
new_balance = starting_point + tokens_to_add
|
| 168 |
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
|
| 171 |
transaction = db.transaction()
|
| 172 |
update_in_transaction(transaction, doc_ref)
|
| 173 |
-
logger.info(f"💰 [QUOTA_V2] Added {tokens_to_add} tokens to {uid}.
|
| 174 |
except Exception as e:
|
| 175 |
logger.error(f"❌ [QUOTA_V2] Failed to add tokens with absorption for {uid}: {e}")
|
| 176 |
|
| 177 |
def increment_usage(self, uid: str, increment_questions: int = 1, tokens_used: int = 0):
|
|
|
|
|
|
|
|
|
|
| 178 |
if not uid:
|
| 179 |
return
|
| 180 |
|
|
@@ -191,17 +197,29 @@ class QuotaManagerV2:
|
|
| 191 |
def update_in_transaction(transaction, doc_ref):
|
| 192 |
snapshot = doc_ref.get(transaction=transaction)
|
| 193 |
if not snapshot.exists:
|
|
|
|
|
|
|
| 194 |
transaction.set(doc_ref, {
|
| 195 |
"quota_limit": self.default_limit,
|
| 196 |
"used_today": increment_questions,
|
| 197 |
"used_tokens_this_month": tokens_used,
|
| 198 |
"last_usage_date": today,
|
| 199 |
"last_usage_month": this_month,
|
| 200 |
-
"last_seen": firestore.SERVER_TIMESTAMP
|
|
|
|
| 201 |
})
|
| 202 |
return
|
| 203 |
|
| 204 |
data = snapshot.to_dict()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
last_date = data.get("last_usage_date", "")
|
| 206 |
last_month = data.get("last_usage_month", "")
|
| 207 |
|
|
@@ -220,23 +238,17 @@ class QuotaManagerV2:
|
|
| 220 |
# V5.9.8: Lifetime Tracking & Economics
|
| 221 |
total_used = data.get("total_tokens_used", 0) + tokens_used
|
| 222 |
|
| 223 |
-
# Calculate cost delta
|
| 224 |
from cost_tracker import PRICING
|
| 225 |
-
# We assume a balanced input/output for a rough estimate if only total_tokens is provided,
|
| 226 |
-
# or we just use an average price if we don't have the split.
|
| 227 |
-
# Average price = (0.10 + 0.40) / 2 = 0.25 / 1M tokens
|
| 228 |
avg_price = (PRICING["input"] + PRICING["output"]) / 2
|
| 229 |
cost_delta = (tokens_used / 1e6) * avg_price
|
| 230 |
total_cost = data.get("total_cost_usd", 0.0) + cost_delta
|
| 231 |
-
|
| 232 |
-
# V5.13.8: Lifetime Tracking (Exercises)
|
| 233 |
total_exercises = data.get("total_exercises_solved", 0) + increment_questions
|
| 234 |
|
| 235 |
transaction.update(doc_ref, {
|
| 236 |
"used_today": new_usage,
|
| 237 |
"used_tokens_this_month": new_tokens,
|
| 238 |
"total_tokens_used": total_used,
|
| 239 |
-
"total_exercises_solved": total_exercises,
|
| 240 |
"total_cost_usd": total_cost,
|
| 241 |
"last_usage_date": today,
|
| 242 |
"last_active_date": today,
|
|
@@ -247,10 +259,14 @@ class QuotaManagerV2:
|
|
| 247 |
|
| 248 |
transaction = db.transaction()
|
| 249 |
update_in_transaction(transaction, doc_ref)
|
| 250 |
-
logger.info(f"📊 [QUOTA_V2] Incremented usage for {uid}.
|
| 251 |
|
|
|
|
|
|
|
|
|
|
| 252 |
except Exception as e:
|
| 253 |
logger.error(f"❌ [QUOTA_V2] Failed to increment usage for {uid}: {e}")
|
|
|
|
| 254 |
|
| 255 |
# Global instance
|
| 256 |
quota_manager_v2 = QuotaManagerV2()
|
|
|
|
| 54 |
return False, "User is blocked", 0, 0
|
| 55 |
|
| 56 |
# Admin or special roles
|
| 57 |
+
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:
|
| 58 |
+
return True, "Unlimited (Admin/VIP)", 0, -1
|
| 59 |
|
| 60 |
# --- Device ID Enforcement ---
|
| 61 |
if device_id:
|
|
|
|
| 111 |
# Fail closed or open? Let's fail open temporarily so we don't block users if DB hiccups
|
| 112 |
return True, "Error - Fallback Allow", 0, self.default_limit
|
| 113 |
|
| 114 |
+
def check_wallet(self, uid: str, min_required: int = 2000):
|
| 115 |
"""
|
| 116 |
+
V5.15.0: Strict token balance check for Pencil Economy (Pre-flight).
|
| 117 |
+
1 Pencil = 17,000 tokens.
|
| 118 |
"""
|
| 119 |
if not uid or uid == "dev-bypass-user":
|
| 120 |
+
return True, 1000000
|
| 121 |
|
| 122 |
db = self._get_db()
|
| 123 |
if not db:
|
|
|
|
| 127 |
doc_ref = db.collection(self.collection_name).document(uid)
|
| 128 |
doc = doc_ref.get()
|
| 129 |
if not doc.exists:
|
| 130 |
+
return False, 0
|
| 131 |
|
| 132 |
data = doc.to_dict()
|
| 133 |
|
| 134 |
+
# Admin Bypass
|
| 135 |
+
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:
|
| 136 |
return True, 1000000
|
| 137 |
|
| 138 |
wallet = data.get('wallet', {})
|
| 139 |
token_balance = wallet.get('token_balance', 0)
|
| 140 |
|
| 141 |
+
# Strict check against min_required
|
| 142 |
+
return token_balance >= min_required, token_balance
|
| 143 |
except Exception as e:
|
| 144 |
logger.error(f"❌ [QUOTA_V2] Failed to check wallet for {uid}: {e}")
|
| 145 |
+
return True, 0
|
| 146 |
|
| 147 |
def add_tokens_with_absorption(self, uid: str, tokens_to_add: int):
|
| 148 |
"""
|
| 149 |
+
V5.15.0: Add tokens with "Debt Absorption" and "Total Purchased" logic.
|
|
|
|
| 150 |
"""
|
| 151 |
db = self._get_db()
|
| 152 |
if not db:
|
|
|
|
| 165 |
starting_point = max(0, current_balance)
|
| 166 |
new_balance = starting_point + tokens_to_add
|
| 167 |
|
| 168 |
+
# Update both balance and total purchased (capacity)
|
| 169 |
+
transaction.update(doc_ref, {
|
| 170 |
+
"wallet.token_balance": new_balance,
|
| 171 |
+
"wallet.total_purchased_tokens": firestore.Increment(tokens_to_add)
|
| 172 |
+
})
|
| 173 |
|
| 174 |
transaction = db.transaction()
|
| 175 |
update_in_transaction(transaction, doc_ref)
|
| 176 |
+
logger.info(f"💰 [QUOTA_V2] Added {tokens_to_add} tokens to {uid}. Total capacity increased.")
|
| 177 |
except Exception as e:
|
| 178 |
logger.error(f"❌ [QUOTA_V2] Failed to add tokens with absorption for {uid}: {e}")
|
| 179 |
|
| 180 |
def increment_usage(self, uid: str, increment_questions: int = 1, tokens_used: int = 0):
|
| 181 |
+
"""
|
| 182 |
+
V5.15.0: Atomic increment with Overdraft Prevention.
|
| 183 |
+
"""
|
| 184 |
if not uid:
|
| 185 |
return
|
| 186 |
|
|
|
|
| 197 |
def update_in_transaction(transaction, doc_ref):
|
| 198 |
snapshot = doc_ref.get(transaction=transaction)
|
| 199 |
if not snapshot.exists:
|
| 200 |
+
if tokens_used > 0:
|
| 201 |
+
raise ValueError("Insufficient Pencils: User document missing")
|
| 202 |
transaction.set(doc_ref, {
|
| 203 |
"quota_limit": self.default_limit,
|
| 204 |
"used_today": increment_questions,
|
| 205 |
"used_tokens_this_month": tokens_used,
|
| 206 |
"last_usage_date": today,
|
| 207 |
"last_usage_month": this_month,
|
| 208 |
+
"last_seen": firestore.SERVER_TIMESTAMP,
|
| 209 |
+
"wallet": {"token_balance": 0, "total_purchased_tokens": 0}
|
| 210 |
})
|
| 211 |
return
|
| 212 |
|
| 213 |
data = snapshot.to_dict()
|
| 214 |
+
|
| 215 |
+
# V5.15.0: STRICT OVERDRAFT PREVENTION
|
| 216 |
+
if tokens_used > 0:
|
| 217 |
+
wallet = data.get('wallet', {})
|
| 218 |
+
current_balance = wallet.get('token_balance', 0)
|
| 219 |
+
if current_balance < tokens_used:
|
| 220 |
+
logger.warning(f"🚫 [QUOTA_V2] Overdraft blocked for {uid}: {current_balance} < {tokens_used}")
|
| 221 |
+
raise ValueError("Insufficient Pencils")
|
| 222 |
+
|
| 223 |
last_date = data.get("last_usage_date", "")
|
| 224 |
last_month = data.get("last_usage_month", "")
|
| 225 |
|
|
|
|
| 238 |
# V5.9.8: Lifetime Tracking & Economics
|
| 239 |
total_used = data.get("total_tokens_used", 0) + tokens_used
|
| 240 |
|
|
|
|
| 241 |
from cost_tracker import PRICING
|
|
|
|
|
|
|
|
|
|
| 242 |
avg_price = (PRICING["input"] + PRICING["output"]) / 2
|
| 243 |
cost_delta = (tokens_used / 1e6) * avg_price
|
| 244 |
total_cost = data.get("total_cost_usd", 0.0) + cost_delta
|
|
|
|
|
|
|
| 245 |
total_exercises = data.get("total_exercises_solved", 0) + increment_questions
|
| 246 |
|
| 247 |
transaction.update(doc_ref, {
|
| 248 |
"used_today": new_usage,
|
| 249 |
"used_tokens_this_month": new_tokens,
|
| 250 |
"total_tokens_used": total_used,
|
| 251 |
+
"total_exercises_solved": total_exercises,
|
| 252 |
"total_cost_usd": total_cost,
|
| 253 |
"last_usage_date": today,
|
| 254 |
"last_active_date": today,
|
|
|
|
| 259 |
|
| 260 |
transaction = db.transaction()
|
| 261 |
update_in_transaction(transaction, doc_ref)
|
| 262 |
+
logger.info(f"📊 [QUOTA_V2] Incremented usage for {uid}. Tokens deducted: {tokens_used}")
|
| 263 |
|
| 264 |
+
except ValueError as ve:
|
| 265 |
+
# Re-raise for main.py to catch
|
| 266 |
+
raise ve
|
| 267 |
except Exception as e:
|
| 268 |
logger.error(f"❌ [QUOTA_V2] Failed to increment usage for {uid}: {e}")
|
| 269 |
+
raise e
|
| 270 |
|
| 271 |
# Global instance
|
| 272 |
quota_manager_v2 = QuotaManagerV2()
|
report_aggregator.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# buddy_math_server/report_aggregator.py
|
| 2 |
+
import time
|
| 3 |
+
import logging
|
| 4 |
+
from collections import defaultdict
|
| 5 |
+
from firebase_manager import firebase_manager
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
class ReportAggregator:
|
| 10 |
+
def __init__(self):
|
| 11 |
+
self.db = firebase_manager.get_db()
|
| 12 |
+
|
| 13 |
+
def get_summary_for_period(self, uid: str, days=7):
|
| 14 |
+
"""
|
| 15 |
+
Aggregates metrics for the last 7 days.
|
| 16 |
+
"""
|
| 17 |
+
if not self.db:
|
| 18 |
+
return None
|
| 19 |
+
|
| 20 |
+
start_time = time.time() - (days * 24 * 60 * 60)
|
| 21 |
+
|
| 22 |
+
summary = {
|
| 23 |
+
"total_interactions": 0,
|
| 24 |
+
"mastery_by_category": defaultdict(list),
|
| 25 |
+
"completed_challenges": 0,
|
| 26 |
+
"teacher_notes": [],
|
| 27 |
+
"top_categories": set()
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
try:
|
| 31 |
+
# 1. Query Analytics
|
| 32 |
+
analytics_docs = self.db.collection('users').document(uid).collection('analytics') \
|
| 33 |
+
.where('timestamp', '>', start_time).stream()
|
| 34 |
+
|
| 35 |
+
for doc in analytics_docs:
|
| 36 |
+
data = doc.to_dict()
|
| 37 |
+
summary["total_interactions"] += 1
|
| 38 |
+
|
| 39 |
+
cat = data.get('category', 'כללי')
|
| 40 |
+
score = data.get('mastery_score')
|
| 41 |
+
if score is not None:
|
| 42 |
+
summary["mastery_by_category"][cat].append(score)
|
| 43 |
+
summary["top_categories"].add(cat)
|
| 44 |
+
|
| 45 |
+
note = data.get('parent_note') or data.get('teacher_summary')
|
| 46 |
+
if note:
|
| 47 |
+
summary["teacher_notes"].append(note)
|
| 48 |
+
|
| 49 |
+
# 2. Query Completed Suggestions (Challenges)
|
| 50 |
+
suggestions = self.db.collection('users').document(uid).collection('suggestions') \
|
| 51 |
+
.where('status', '==', 'completed') \
|
| 52 |
+
.where('created_at', '>', start_time).stream()
|
| 53 |
+
|
| 54 |
+
for sug in suggestions:
|
| 55 |
+
summary["completed_challenges"] += 1
|
| 56 |
+
|
| 57 |
+
# 3. Calculate Averages
|
| 58 |
+
avg_mastery = {}
|
| 59 |
+
for cat, scores in summary["mastery_by_category"].items():
|
| 60 |
+
avg_mastery[cat] = sum(scores) / len(scores)
|
| 61 |
+
|
| 62 |
+
summary["avg_mastery"] = avg_mastery
|
| 63 |
+
|
| 64 |
+
return summary
|
| 65 |
+
|
| 66 |
+
except Exception as e:
|
| 67 |
+
logger.error(f"❌ [AGGREGATOR] Error: {e}")
|
| 68 |
+
return None
|
| 69 |
+
|
| 70 |
+
report_aggregator = ReportAggregator()
|
report_delivery_service.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# buddy_math_server/report_delivery_service.py
|
| 2 |
+
import os
|
| 3 |
+
import logging
|
| 4 |
+
from jinja2 import Template
|
| 5 |
+
from sendgrid import SendGridAPIClient
|
| 6 |
+
from sendgrid.helpers.mail import Mail, Attachment, FileContent, FileName, FileType, Disposition
|
| 7 |
+
import base64
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
class ReportDeliveryService:
|
| 12 |
+
def __init__(self):
|
| 13 |
+
self.api_key = os.environ.get('SENDGRID_API_KEY')
|
| 14 |
+
self.from_email = 'tutor@buddy-math.com'
|
| 15 |
+
|
| 16 |
+
def render_html(self, template_path, data):
|
| 17 |
+
"""
|
| 18 |
+
Renders the Jinja2 template with data.
|
| 19 |
+
"""
|
| 20 |
+
with open(template_path, 'r', encoding='utf-8') as f:
|
| 21 |
+
template_content = f.read()
|
| 22 |
+
template = Template(template_content)
|
| 23 |
+
return template.render(data)
|
| 24 |
+
|
| 25 |
+
def generate_pdf(self, html_content, output_path):
|
| 26 |
+
"""
|
| 27 |
+
Converts HTML to PDF.
|
| 28 |
+
Note: Requires xhtml2pdf or pdfkit.
|
| 29 |
+
Using xhtml2pdf for simplicity in pure python environments.
|
| 30 |
+
"""
|
| 31 |
+
try:
|
| 32 |
+
from xhtml2pdf import pisa
|
| 33 |
+
with open(output_path, "wb") as f:
|
| 34 |
+
pisa_status = pisa.CreatePDF(html_content, dest=f)
|
| 35 |
+
return not pisa_status.err
|
| 36 |
+
except ImportError:
|
| 37 |
+
logger.error("❌ [DELIVERY] xhtml2pdf not installed. Saving as HTML instead.")
|
| 38 |
+
with open(output_path.replace('.pdf', '.html'), 'w', encoding='utf-8') as f:
|
| 39 |
+
f.write(html_content)
|
| 40 |
+
return False
|
| 41 |
+
|
| 42 |
+
def send_email_with_attachment(self, to_email, subject, body, attachment_path, student_name):
|
| 43 |
+
"""
|
| 44 |
+
Sends an email via SendGrid with the report attached.
|
| 45 |
+
"""
|
| 46 |
+
if not self.api_key:
|
| 47 |
+
logger.error("❌ [DELIVERY] SENDGRID_API_KEY missing.")
|
| 48 |
+
return False
|
| 49 |
+
|
| 50 |
+
message = Mail(
|
| 51 |
+
from_email=self.from_email,
|
| 52 |
+
to_emails=to_email,
|
| 53 |
+
subject=subject,
|
| 54 |
+
html_content=body
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
try:
|
| 58 |
+
with open(attachment_path, 'rb') as f:
|
| 59 |
+
data = f.read()
|
| 60 |
+
encoded_file = base64.b64encode(data).decode()
|
| 61 |
+
|
| 62 |
+
attachedFile = Attachment(
|
| 63 |
+
FileContent(encoded_file),
|
| 64 |
+
FileName(f'BuddyMath_Report_{student_name}.pdf'),
|
| 65 |
+
FileType('application/pdf'),
|
| 66 |
+
Disposition('attachment')
|
| 67 |
+
)
|
| 68 |
+
message.add_attachment(attachedFile)
|
| 69 |
+
|
| 70 |
+
sg = SendGridAPIClient(self.api_key)
|
| 71 |
+
response = sg.send(message)
|
| 72 |
+
logger.info(f"🚀 [DELIVERY] Email sent to {to_email}. Status: {response.status_code}")
|
| 73 |
+
return True
|
| 74 |
+
except Exception as e:
|
| 75 |
+
logger.error(f"❌ [DELIVERY] SendGrid Error: {e}")
|
| 76 |
+
return False
|
| 77 |
+
|
| 78 |
+
report_delivery_service = ReportDeliveryService()
|
report_generator.py
CHANGED
|
@@ -1,257 +1,74 @@
|
|
| 1 |
-
|
| 2 |
import json
|
| 3 |
-
import
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
try:
|
| 8 |
-
import pdfkit
|
| 9 |
-
HAS_PDFKIT = True
|
| 10 |
-
except ImportError:
|
| 11 |
-
HAS_PDFKIT = False
|
| 12 |
-
|
| 13 |
-
# Fallback to weasyprint if preferred/available
|
| 14 |
-
try:
|
| 15 |
-
from weasyprint import HTML
|
| 16 |
-
HAS_WEASYPRINT = True
|
| 17 |
-
except Exception as e:
|
| 18 |
-
print(f"Weasyprint not available: {e}")
|
| 19 |
-
HAS_WEASYPRINT = False
|
| 20 |
-
|
| 21 |
-
import sendgrid
|
| 22 |
-
from sendgrid.helpers.mail import Mail, Attachment, FileContent, FileName, FileType, Disposition
|
| 23 |
-
import base64
|
| 24 |
-
|
| 25 |
-
from firebase_manager import firebase_manager
|
| 26 |
|
| 27 |
class ReportGenerator:
|
| 28 |
-
"""
|
| 29 |
-
Generates weekly Parent Reports in HTML and PDF formats natively.
|
| 30 |
-
"""
|
| 31 |
def __init__(self):
|
| 32 |
-
self.
|
| 33 |
-
# Ensure templates directory exists
|
| 34 |
-
os.makedirs(self.templates_dir, exist_ok=True)
|
| 35 |
-
self.jinja_env = Environment(loader=FileSystemLoader(self.templates_dir))
|
| 36 |
|
| 37 |
-
def
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
-
def
|
| 41 |
"""
|
| 42 |
-
|
| 43 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
chart_config = {
|
| 45 |
"type": "radar",
|
| 46 |
"data": {
|
| 47 |
"labels": labels,
|
| 48 |
"datasets": [{
|
| 49 |
-
"label":
|
| 50 |
-
"data":
|
| 51 |
-
"backgroundColor": "rgba(
|
| 52 |
-
"borderColor": "
|
| 53 |
-
"pointBackgroundColor": "
|
| 54 |
-
"pointBorderColor": "#fff",
|
| 55 |
-
"pointHoverBackgroundColor": "#fff",
|
| 56 |
-
"pointHoverBorderColor": "rgba(59, 130, 246, 1)"
|
| 57 |
}]
|
| 58 |
},
|
| 59 |
"options": {
|
| 60 |
"scale": {
|
| 61 |
-
"ticks": {
|
| 62 |
-
|
| 63 |
-
},
|
| 64 |
-
"legend": { "display": False }
|
| 65 |
}
|
| 66 |
}
|
| 67 |
-
encoded_config = urllib.parse.quote(json.dumps(chart_config))
|
| 68 |
-
# Use high resolution for PDF printing
|
| 69 |
-
return f"https://quickchart.io/chart?w=500&h=500&c={encoded_config}"
|
| 70 |
-
|
| 71 |
-
def produce_weekly_report(self, uid: str, week_id: str, student_name: str = "הת��מיד"):
|
| 72 |
-
"""
|
| 73 |
-
Fetches data from Firestore, renders the HTML template, and returns the HTML string.
|
| 74 |
-
"""
|
| 75 |
-
db = self._get_db()
|
| 76 |
-
if not db:
|
| 77 |
-
raise Exception("Firestore DB not initialized.")
|
| 78 |
-
|
| 79 |
-
doc_ref = db.collection("users").document(uid).collection("analytics").document(week_id)
|
| 80 |
-
doc = doc_ref.get()
|
| 81 |
-
|
| 82 |
-
if not doc.exists:
|
| 83 |
-
raise Exception(f"No analytics data found for {uid} in {week_id}")
|
| 84 |
-
|
| 85 |
-
data = doc.to_dict()
|
| 86 |
-
|
| 87 |
-
# Process Skills metrics
|
| 88 |
-
skills_raw = data.get("skills_data", {})
|
| 89 |
-
skills_processed = []
|
| 90 |
-
chart_labels = []
|
| 91 |
-
chart_data = []
|
| 92 |
-
total_score_sum = 0
|
| 93 |
-
total_skills_count = 0
|
| 94 |
-
|
| 95 |
-
# Known taxonomy to ensure a consistent radar outline (even if 0)
|
| 96 |
-
taxonomy = ["אלגברה", "גיאומטריה", "טריגונומטריה", "הסתברות", "חשבון דיפרנציאלי"]
|
| 97 |
-
|
| 98 |
-
for skill_name in taxonomy:
|
| 99 |
-
chart_labels.append(skill_name)
|
| 100 |
-
if skill_name in skills_raw:
|
| 101 |
-
skill_info = skills_raw[skill_name]
|
| 102 |
-
s_sum = skill_info.get("sum_scores", 0)
|
| 103 |
-
s_count = skill_info.get("count", 0)
|
| 104 |
-
s_avg = int(s_sum / s_count) if s_count > 0 else 0
|
| 105 |
-
|
| 106 |
-
chart_data.append(s_avg)
|
| 107 |
-
|
| 108 |
-
total_score_sum += s_avg
|
| 109 |
-
total_skills_count += 1
|
| 110 |
-
|
| 111 |
-
skills_processed.append({
|
| 112 |
-
"name": skill_name,
|
| 113 |
-
"score": s_avg,
|
| 114 |
-
"sub_skills": skill_info.get("sub_skills", [])
|
| 115 |
-
})
|
| 116 |
-
else:
|
| 117 |
-
chart_data.append(0)
|
| 118 |
-
|
| 119 |
-
overall_mastery = int(total_score_sum / total_skills_count) if total_skills_count > 0 else 0
|
| 120 |
-
total_exercises = data.get("total_exercises", 0)
|
| 121 |
-
parent_notes = data.get("parent_notes", [])
|
| 122 |
-
|
| 123 |
-
# Deduplicate parent notes and limit to top 5
|
| 124 |
-
parent_notes = list(set(parent_notes))[:5]
|
| 125 |
-
|
| 126 |
-
# Identify "Focus for next week" (Lowest mastery score that is > 0 or first parent note)
|
| 127 |
-
focus_for_next_week = ""
|
| 128 |
-
if skills_processed:
|
| 129 |
-
# Sort skills by score ascending
|
| 130 |
-
sorted_skills = sorted(skills_processed, key=lambda x: x["score"])
|
| 131 |
-
lowest_skill = sorted_skills[0]
|
| 132 |
-
if lowest_skill["score"] < 80:
|
| 133 |
-
focus_target = ", ".join(lowest_skill['sub_skills']) if lowest_skill['sub_skills'] else lowest_skill['name']
|
| 134 |
-
focus_for_next_week = f"חזרה על {focus_target} לחיזוק השליטה בנושא."
|
| 135 |
|
| 136 |
-
|
| 137 |
-
|
| 138 |
|
| 139 |
-
# Generate Chart
|
| 140 |
-
chart_url = self.generate_quickchart_url(chart_labels, chart_data)
|
| 141 |
-
|
| 142 |
-
# Template Rendering
|
| 143 |
-
template = self.jinja_env.get_template("report_template.html")
|
| 144 |
-
html_content = template.render(
|
| 145 |
-
week_label=week_id.replace("week_", "").replace("_", "/"),
|
| 146 |
-
student_name=student_name,
|
| 147 |
-
total_exercises=total_exercises,
|
| 148 |
-
overall_mastery=overall_mastery,
|
| 149 |
-
skills=skills_processed,
|
| 150 |
-
chart_url=chart_url,
|
| 151 |
-
parent_notes=parent_notes,
|
| 152 |
-
focus_for_next_week=focus_for_next_week
|
| 153 |
-
)
|
| 154 |
-
|
| 155 |
-
return html_content
|
| 156 |
-
|
| 157 |
-
def export_to_pdf(self, html_content: str, output_path: str):
|
| 158 |
-
"""
|
| 159 |
-
Converts the rendered HTML to a PDF file.
|
| 160 |
-
Prefer WeasyPrint if available (better CSS/font support), fallback to pdfkit.
|
| 161 |
-
"""
|
| 162 |
-
if HAS_WEASYPRINT:
|
| 163 |
-
HTML(string=html_content).write_pdf(output_path)
|
| 164 |
-
return True
|
| 165 |
-
elif HAS_PDFKIT:
|
| 166 |
-
options = {
|
| 167 |
-
'page-size': 'A4',
|
| 168 |
-
'margin-top': '0.75in',
|
| 169 |
-
'margin-right': '0.75in',
|
| 170 |
-
'margin-bottom': '0.75in',
|
| 171 |
-
'margin-left': '0.75in',
|
| 172 |
-
'encoding': "UTF-8",
|
| 173 |
-
'no-outline': None
|
| 174 |
-
}
|
| 175 |
-
pdfkit.from_string(html_content, output_path, options=options)
|
| 176 |
-
return True
|
| 177 |
-
else:
|
| 178 |
-
raise Exception("No PDF rendering library found. Please install 'weasyprint' or 'pdfkit'.")
|
| 179 |
-
|
| 180 |
-
def send_report_email(self, parent_email: str, student_name: str, pdf_path: str):
|
| 181 |
-
"""
|
| 182 |
-
Sends the generated PDF report via SendGrid.
|
| 183 |
-
"""
|
| 184 |
-
sg_api_key = os.environ.get("SENDGRID_API_KEY")
|
| 185 |
-
if not sg_api_key:
|
| 186 |
-
print("⚠️ [EMAIL] SENDGRID_API_KEY not found in environment. Skipping email.")
|
| 187 |
-
return False
|
| 188 |
-
|
| 189 |
-
try:
|
| 190 |
-
sg = sendgrid.SendGridAPIClient(api_key=sg_api_key)
|
| 191 |
-
|
| 192 |
-
# The sender email must be verified in SendGrid
|
| 193 |
-
from_email = os.environ.get("SENDGRID_FROM_EMAIL", "reports@buddymath.co.il")
|
| 194 |
-
subject = f"איך עבר השבוע של {student_name} במתמטיקה? 📈 הדוח השבועי מ-המורה למתמטיקה בפנים."
|
| 195 |
-
|
| 196 |
-
# Simple content
|
| 197 |
-
content = "היי! מצורף דוח התקדמות שבועי המפרט את המיומנויות שתורגלו במהלך השבוע. נמשיך לתרגל ולהשתפר!\n\nצוות המורה למתמטיקה 👨🏫"
|
| 198 |
-
|
| 199 |
-
message = Mail(
|
| 200 |
-
from_email=from_email,
|
| 201 |
-
to_emails=parent_email,
|
| 202 |
-
subject=subject,
|
| 203 |
-
plain_text_content=content
|
| 204 |
-
)
|
| 205 |
-
|
| 206 |
-
# Attach PDF
|
| 207 |
-
with open(pdf_path, 'rb') as f:
|
| 208 |
-
data = f.read()
|
| 209 |
-
encoded_file = base64.b64encode(data).decode()
|
| 210 |
-
|
| 211 |
-
attachment = Attachment(
|
| 212 |
-
FileContent(encoded_file),
|
| 213 |
-
FileName(f"Math_Teacher_Weekly_Report_{student_name}.pdf"),
|
| 214 |
-
FileType('application/pdf'),
|
| 215 |
-
Disposition('attachment')
|
| 216 |
-
)
|
| 217 |
-
message.attachment = attachment
|
| 218 |
-
|
| 219 |
-
response = sg.send(message)
|
| 220 |
-
print(f"📧 [EMAIL] Report sent to {parent_email}. Status code: {response.status_code}")
|
| 221 |
-
return response.status_code in [200, 202]
|
| 222 |
-
|
| 223 |
-
except Exception as e:
|
| 224 |
-
print(f"❌ [EMAIL] SendGrid failed: {e}")
|
| 225 |
-
return False
|
| 226 |
-
|
| 227 |
-
# Global Instance
|
| 228 |
report_generator = ReportGenerator()
|
| 229 |
-
|
| 230 |
-
if __name__ == "__main__":
|
| 231 |
-
# Test script locally with mockup data if running directly
|
| 232 |
-
print("Running Report Generator Test...")
|
| 233 |
-
import asyncio
|
| 234 |
-
|
| 235 |
-
gen = ReportGenerator()
|
| 236 |
-
labels = ["אלגברה", "גיאומטריה", "טריגונומטריה", "הסתברות", "חשבון דיפרנציאלי"]
|
| 237 |
-
data = [85, 90, 60, 100, 40]
|
| 238 |
-
url = gen.generate_quickchart_url(labels, data)
|
| 239 |
-
print("QuickChart URL:", url)
|
| 240 |
-
|
| 241 |
-
mock_html = gen.jinja_env.get_template("report_template.html").render(
|
| 242 |
-
week_label="11/2026",
|
| 243 |
-
student_name="דותן הרוש",
|
| 244 |
-
total_exercises=14,
|
| 245 |
-
overall_mastery=75,
|
| 246 |
-
skills=[
|
| 247 |
-
{"name": "אלגברה", "score": 85, "sub_skills": ["משוואות ממעלה ראשונה", "חוקי חזקות"]},
|
| 248 |
-
{"name": "גיאומטריה", "score": 90, "sub_skills": ["חפיפת משולשים"]},
|
| 249 |
-
],
|
| 250 |
-
chart_url=url,
|
| 251 |
-
parent_notes=["התלמיד גילה הבנה טובה בבידוד משתנים", "שליטה מעולה בחוקי חזקות!"],
|
| 252 |
-
focus_for_next_week="חזרה על נוסחאות הכפל המקוצר לשיפור הדיוק האלגברי."
|
| 253 |
-
)
|
| 254 |
-
|
| 255 |
-
with open("sample_report.html", "w", encoding="utf-8") as f:
|
| 256 |
-
f.write(mock_html)
|
| 257 |
-
print("Generated sample_report.html directly to root dir for browser inspection.")
|
|
|
|
| 1 |
+
# buddy_math_server/report_generator.py
|
| 2 |
import json
|
| 3 |
+
import logging
|
| 4 |
+
import requests
|
| 5 |
+
import google.generativeai as genai
|
| 6 |
+
from config import GEMINI_MODEL
|
| 7 |
+
from report_aggregator import report_aggregator
|
| 8 |
|
| 9 |
+
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
class ReportGenerator:
|
|
|
|
|
|
|
|
|
|
| 12 |
def __init__(self):
|
| 13 |
+
self.model = genai.GenerativeModel(GEMINI_MODEL)
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
+
async def generate_ai_summary(self, data_summary: dict, student_name: str) -> str:
|
| 16 |
+
"""
|
| 17 |
+
Uses Gemini 2.0 to generate a warm pedagogical summary for the parent.
|
| 18 |
+
"""
|
| 19 |
+
# Prepare the data string for the prompt
|
| 20 |
+
mastery_str = ", ".join([f"{cat}: {score:.1f}%" for cat, score in data_summary.get("avg_mastery", {}).items()])
|
| 21 |
+
|
| 22 |
+
prompt = f"""בשם 'המורה הפרטית' של Buddy-Math, כתוב סיכום שבועי להורה של {student_name}.
|
| 23 |
+
נתוני הלמידה השבוע:
|
| 24 |
+
- סה"כ תרגילים שבוצעו: {data_summary['total_interactions']}
|
| 25 |
+
- ציון ממוצע לפי נושא: {mastery_str}
|
| 26 |
+
- אתגרים אקטיביים שהושלמו: {data_summary['completed_challenges']}
|
| 27 |
+
- הערות מורה שנאספו: {"; ".join(data_summary['teacher_notes'][:5])}
|
| 28 |
+
|
| 29 |
+
הוראות:
|
| 30 |
+
1. כתוב פסקה קצרה (3-4 שורות) בעברית חמה, מעודדת ומקצועית.
|
| 31 |
+
2. הדגש נושאים שבהם התלמיד השתפר והפגנת התמדה.
|
| 32 |
+
3. הוסף טיפ פדגוגי אחד קטן להמשך.
|
| 33 |
+
4. אל תשתמש בפורמט של מכתב רשמי, אלא בפנייה ישירה וחמה.
|
| 34 |
+
"""
|
| 35 |
+
try:
|
| 36 |
+
res = await self.model.generate_content_async(prompt)
|
| 37 |
+
return res.text.strip()
|
| 38 |
+
except Exception as e:
|
| 39 |
+
logger.error(f"❌ [REPORT-GEN] AI Summary Error: {e}")
|
| 40 |
+
return "התלמיד התקדם יפה השבוע במתמטיקה והפגין סקרנות רבה."
|
| 41 |
|
| 42 |
+
def generate_radar_chart_url(self, avg_mastery: dict) -> str:
|
| 43 |
"""
|
| 44 |
+
Generates a QuickChart URL for a Radar Chart.
|
| 45 |
"""
|
| 46 |
+
labels = list(avg_mastery.keys())
|
| 47 |
+
values = list(avg_mastery.values())
|
| 48 |
+
|
| 49 |
+
if not labels:
|
| 50 |
+
return "https://quickchart.io/chart?c={type:'radar',data:{labels:['כללי'],datasets:[{label:'התקדמות',data:[0]}]}}"
|
| 51 |
+
|
| 52 |
chart_config = {
|
| 53 |
"type": "radar",
|
| 54 |
"data": {
|
| 55 |
"labels": labels,
|
| 56 |
"datasets": [{
|
| 57 |
+
"label": "רמת שליטה (%)",
|
| 58 |
+
"data": values,
|
| 59 |
+
"backgroundColor": "rgba(33, 150, 243, 0.2)",
|
| 60 |
+
"borderColor": "rgb(33, 150, 243)",
|
| 61 |
+
"pointBackgroundColor": "rgb(33, 150, 243)"
|
|
|
|
|
|
|
|
|
|
| 62 |
}]
|
| 63 |
},
|
| 64 |
"options": {
|
| 65 |
"scale": {
|
| 66 |
+
"ticks": {"beginAtZero": True, "max": 100}
|
| 67 |
+
}
|
|
|
|
|
|
|
| 68 |
}
|
| 69 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
+
url = f"https://quickchart.io/chart?c={json.dumps(chart_config)}"
|
| 72 |
+
return url
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
report_generator = ReportGenerator()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
report_template.html
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="he" dir="rtl">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<title>דו"ח למידה שבועי - Buddy-Math</title>
|
| 6 |
+
<style>
|
| 7 |
+
body { font-family: 'Arial', sans-serif; background-color: #f4f7f6; color: #333; margin: 0; padding: 20px; }
|
| 8 |
+
.container { background-color: #fff; padding: 40px; border-radius: 10px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); max-width: 800px; margin: auto; }
|
| 9 |
+
.header { text-align: center; border-bottom: 2px solid #2196F3; padding-bottom: 20px; margin-bottom: 30px; }
|
| 10 |
+
.header h1 { color: #2196F3; margin: 0; }
|
| 11 |
+
.summary-box { background-color: #e3f2fd; padding: 20px; border-right: 5px solid #2196F3; border-radius: 4px; margin-bottom: 30px; line-height: 1.6; }
|
| 12 |
+
.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin-bottom: 30px; text-align: center; }
|
| 13 |
+
.stat-item { background: #fafafa; padding: 15px; border-radius: 8px; border: 1px solid #eee; }
|
| 14 |
+
.stat-value { font-size: 24px; font-weight: bold; color: #2196F3; }
|
| 15 |
+
.stat-label { font-size: 14px; color: #666; }
|
| 16 |
+
.chart-container { text-align: center; margin: 40px 0; }
|
| 17 |
+
.chart-container img { max-width: 100%; height: auto; border-radius: 8px; }
|
| 18 |
+
.footer { text-align: center; font-size: 12px; color: #999; margin-top: 50px; }
|
| 19 |
+
</style>
|
| 20 |
+
</head>
|
| 21 |
+
<body>
|
| 22 |
+
<div class="container">
|
| 23 |
+
<div class="header">
|
| 24 |
+
<h1>איך עבר השבוע של {{ student_name }}? ✏️</h1>
|
| 25 |
+
<p>סיכום למידה פדגוגי שבועי</p>
|
| 26 |
+
</div>
|
| 27 |
+
|
| 28 |
+
<div class="stats-grid">
|
| 29 |
+
<div class="stat-item">
|
| 30 |
+
<div class="stat-value">{{ total_interactions }}</div>
|
| 31 |
+
<div class="stat-label">אינטראקציות השבוע</div>
|
| 32 |
+
</div>
|
| 33 |
+
<div class="stat-item">
|
| 34 |
+
<div class="stat-value">{{ completed_challenges }}</div>
|
| 35 |
+
<div class="stat-label">אתגרים שהושלמו</div>
|
| 36 |
+
</div>
|
| 37 |
+
<div class="stat-item">
|
| 38 |
+
<div class="stat-value">{{ avg_mastery_pct | round(1) }}%</div>
|
| 39 |
+
<div class="stat-label">ממוצע שליטה כללי</div>
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
<div class="summary-box">
|
| 44 |
+
<h3>דבר המורה:</h3>
|
| 45 |
+
<p>{{ ai_summary }}</p>
|
| 46 |
+
</div>
|
| 47 |
+
|
| 48 |
+
<div class="chart-container">
|
| 49 |
+
<h3>מפת המיומנויות של {{ student_name }}</h3>
|
| 50 |
+
<img src="{{ chart_url }}" alt="Radar Chart">
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
<div class="footer">
|
| 54 |
+
<p>נוצר באהבה על ידי Buddy-Math © 2026</p>
|
| 55 |
+
</div>
|
| 56 |
+
</div>
|
| 57 |
+
</body>
|
| 58 |
+
</html>
|
tutor_reminder_engine.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# buddy_math_server/tutor_reminder_engine.py
|
| 2 |
+
import time
|
| 3 |
+
import logging
|
| 4 |
+
from firebase_manager import firebase_manager
|
| 5 |
+
from firebase_admin import messaging
|
| 6 |
+
|
| 7 |
+
logging.basicConfig(level=logging.INFO)
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
def send_push_notification(uid, name, category):
|
| 11 |
+
"""
|
| 12 |
+
Sends a personalized FCM notification to the user.
|
| 13 |
+
"""
|
| 14 |
+
try:
|
| 15 |
+
# 1. Fetch user FCM token (In a real app, this would be in the user doc)
|
| 16 |
+
db = firebase_manager.get_db()
|
| 17 |
+
user_doc = db.collection('users').document(uid).get()
|
| 18 |
+
if not user_doc.exists:
|
| 19 |
+
logger.warning(f"⚠️ [REMINDER] User {uid} not found.")
|
| 20 |
+
return False
|
| 21 |
+
|
| 22 |
+
fcm_token = user_doc.to_dict().get('fcm_token')
|
| 23 |
+
if not fcm_token:
|
| 24 |
+
logger.warning(f"⚠️ [REMINDER] No FCM token for user {uid}.")
|
| 25 |
+
return False
|
| 26 |
+
|
| 27 |
+
# 2. Construct Message
|
| 28 |
+
message_body = f"היי {name}, המורה מחכה לראות איך פתרת את התרגיל ב-{category}! זה ייקח רק 2 דקות. בא לך לנסות? 💪"
|
| 29 |
+
|
| 30 |
+
message = messaging.Message(
|
| 31 |
+
notification=messaging.Notification(
|
| 32 |
+
title="האתגר היומי של Buddy-Math ✏️",
|
| 33 |
+
body=message_body
|
| 34 |
+
),
|
| 35 |
+
token=fcm_token
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
# 3. Send
|
| 39 |
+
response = messaging.send(message)
|
| 40 |
+
logger.info(f"🚀 [REMINDER] Push sent to {uid}: {response}")
|
| 41 |
+
return True
|
| 42 |
+
except Exception as e:
|
| 43 |
+
logger.error(f"❌ [REMINDER] FCM Error: {e}")
|
| 44 |
+
return False
|
| 45 |
+
|
| 46 |
+
def run_reminder_check():
|
| 47 |
+
"""
|
| 48 |
+
V318.0: The Nudge Logic.
|
| 49 |
+
Finds pending suggestions older than 2 hours and sends reminders.
|
| 50 |
+
"""
|
| 51 |
+
try:
|
| 52 |
+
db = firebase_manager.get_db()
|
| 53 |
+
if not db: return
|
| 54 |
+
|
| 55 |
+
# Query all users (In production, use a more efficient index if possible)
|
| 56 |
+
# For this phase, we iterate through suggestions that match criteria
|
| 57 |
+
two_hours_ago = time.time() - (2 * 60 * 60)
|
| 58 |
+
|
| 59 |
+
# We need to query across all users' suggestions.
|
| 60 |
+
# Firestore collectionGroup is ideal here.
|
| 61 |
+
suggestions = db.collection_group('suggestions') \
|
| 62 |
+
.where('status', '==', 'pending') \
|
| 63 |
+
.where('reminder_sent', '==', False) \
|
| 64 |
+
.where('created_at', '<', two_hours_ago) \
|
| 65 |
+
.stream()
|
| 66 |
+
|
| 67 |
+
count = 0
|
| 68 |
+
for sug in suggestions:
|
| 69 |
+
data = sug.to_dict()
|
| 70 |
+
sug_path = sug.reference.path # users/{uid}/suggestions/{sid}
|
| 71 |
+
uid = sug_path.split('/')[1]
|
| 72 |
+
|
| 73 |
+
# Get user name
|
| 74 |
+
user_doc = db.collection('users').document(uid).get()
|
| 75 |
+
name = user_doc.to_dict().get('name', 'חבר')
|
| 76 |
+
category = data.get('category', 'מתמטיקה')
|
| 77 |
+
|
| 78 |
+
# Send Notification
|
| 79 |
+
success = send_push_notification(uid, name, category)
|
| 80 |
+
|
| 81 |
+
if success:
|
| 82 |
+
# Update status
|
| 83 |
+
sug.reference.update({"reminder_sent": True})
|
| 84 |
+
count += 1
|
| 85 |
+
|
| 86 |
+
logger.info(f"✅ [REMINDER] Processed {count} reminders.")
|
| 87 |
+
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logger.error(f"❌ [REMINDER] Engine Error: {e}")
|
| 90 |
+
|
| 91 |
+
if __name__ == "__main__":
|
| 92 |
+
# Ensure Firebase is initialized
|
| 93 |
+
firebase_manager.initialize()
|
| 94 |
+
run_reminder_check()
|