import os import re import json import time import gc import warnings import asyncio import threading import edge_tts import gradio as gr from gradio_client import Client from huggingface_hub import HfApi, hf_hub_download import torch from PIL import Image import sympy as sp from transformers import AutoModelForCausalLM, AutoTokenizer, TrOCRProcessor, VisionEncoderDecoderModel warnings.filterwarnings("ignore") # ════════════════════════════════════════════════════════════════════ # الإعدادات والروابط بين المساحات # ════════════════════════════════════════════════════════════════════ CONTROLLER_SPACE_URL = "Asem75/My_teacher_controller" MEDIA_SPACE_URL = "Asem75/My_teacher_enemation" VAULT_REPO_ID = "Asem75/aiocr_asistant" MODEL_ID = "Qwen/Qwen2.5-3B-Instruct" WHISPER_MODEL = "openai/whisper-medium" OCR_MODEL_ID = "RayR1/trocr-base-arabic-handwritten" # تصحيح: راجع ملف Chat للتفصيل DEVICE = "cpu" MAX_LESSON_CHARS = 1200 RADAR_API_KEY = os.environ.get("INTERNAL_API_KEY", "").strip() HF_TOKEN = os.environ.get("HF_TOKEN", "").strip() if not HF_TOKEN: print("⚠️ تنبيه: HF_TOKEN غير مضبوط — حفظ ملف الطالب قد يفشل.") _hf_api = HfApi(token=HF_TOKEN) if HF_TOKEN else HfApi() # ⚠️ تحميل كسول لكوين بناءً على طلبك: لا يُحمَّل عند إقلاع السيرفر، بل # فقط عند أول استخدام فعلي (أول دخول لغرفة الصف وإرسال سؤال) — لتخفيف # الضغط على المساحة عند الإقلاع وعند التصفح بدون محادثة فعلية. _llm_tokenizer, _llm_model = None, None _qwen_lock = threading.Lock() def load_qwen_lazy(): global _llm_tokenizer, _llm_model if _llm_model is not None: return _llm_tokenizer, _llm_model with _qwen_lock: if _llm_model is not None: return _llm_tokenizer, _llm_model print("⏳ تحميل نموذج كوين (أول استخدام فعلي)...") _llm_tokenizer = AutoTokenizer.from_pretrained(MODEL_ID) _llm_model = AutoModelForCausalLM.from_pretrained( MODEL_ID, torch_dtype=torch.float32, device_map="cpu", low_cpu_mem_usage=True ) print("✅ كوين جاهز") return _llm_tokenizer, _llm_model # قائمة أصوات مايكروسوفت العربية الموثَّقة (نفس القائمة المعتمدة في الرادار) MICROSOFT_VOICES = [ "ar-EG-SalmaNeural", "ar-EG-ShakirNeural", "ar-JO-SanaNeural", "ar-JO-TaimNeural", "ar-SA-ZariyahNeural", "ar-SA-HamedNeural", "ar-AE-FatimaNeural", "ar-AE-HamdanNeural", "ar-IQ-RanaNeural", "ar-IQ-BasselNeural", "ar-LB-LaylaNeural", "ar-LB-RamiNeural", "ar-SY-AmanyNeural", "ar-SY-LaithNeural", "ar-MA-MounaNeural", "ar-MA-JamalNeural", "ar-TN-ReemNeural", "ar-TN-HediNeural", "ar-DZ-AminaNeural", "ar-DZ-IsmaelNeural", "ar-LY-ImanNeural", "ar-LY-OmarNeural", "ar-KW-NouraNeural", "ar-KW-FahedNeural", "ar-QA-AmalNeural", "ar-QA-MoazNeural", "ar-OM-AyshaNeural", "ar-OM-AbdullahNeural", "ar-BH-LailaNeural", "ar-BH-AliNeural", "ar-YE-MaryamNeural", "ar-YE-SalehNeural", ] VOICE_ROLES = ["محاور", "شرح", "أسئلة"] DEFAULT_TEACHER_PREFS = [ {"name": "المعلم الأول", "محاور": "ar-JO-TaimNeural", "شرح": "ar-SA-HamedNeural", "أسئلة": "ar-EG-ShakirNeural"}, {"name": "المعلمة الثانية", "محاور": "ar-EG-SalmaNeural", "شرح": "ar-AE-FatimaNeural", "أسئلة": "ar-LB-LaylaNeural"}, {"name": "المعلم الثالث", "محاور": "ar-SA-HamedNeural", "شرح": "ar-JO-TaimNeural", "أسئلة": "ar-IQ-BasselNeural"}, ] THEME_CSS = { "🌞 نهاري": "", "🌙 ليلي": "body, .gradio-container { background-color:#0b0f19 !important; color:#f3f4f6 !important; } .gr-button { background:#1e293b !important; color:#f3f4f6 !important; }", "🎨 ملوّن": "body, .gradio-container { background: linear-gradient(135deg,#fef3c7,#dbeafe) !important; } .gr-button { background: linear-gradient(135deg,#f59e0b,#3b82f6) !important; color:white !important; }", } def apply_theme(theme_name): css = THEME_CSS.get(theme_name, "") return f"" # ════════════════════════════════════════════════════════════════════ # 🔗 الربط بـ Controller (Phi-4-mini — أستاذ علمي، نص فقط، لا صوت إطلاقاً) # ════════════════════════════════════════════════════════════════════ def ask_science_teacher_via_controller(text, task_type="teach", history=None): try: client = Client(CONTROLLER_SPACE_URL) return client.predict(text, task_type, history or [], api_name="/ask_science_teacher") except Exception as e: print(f"⚠️ خطأ الكنترولر: {e}") return None def deliver_via_qwen(original_question, phi_content, history): """Phi لا يتحدث مع الطالب مباشرة أبداً — يمر عبر كوين دائماً. ⚠️ نبني السجل يدوياً بسؤال الطالب الحقيقي (لا نص التوجيه الداخلي).""" delivery_prompt = ( f"زميلك المعلم المتخصص بالعلوم (Phi) أعطاك هذا المحتوى لسؤال الطالب \"{original_question}\":\n" f"{phi_content}\n\nأعد صياغته بأسلوبك الودود مباشرة للطالب (30 كلمة كحد أقصى، خطوات، سؤال ختامي)." ) response, _ = ask_teacher(delivery_prompt, history) new_history = history + [{"role": "user", "content": original_question}, {"role": "assistant", "content": response}] return response, new_history def ask_teacher(prompt, history): tokenizer, model = load_qwen_lazy() history = history or [] messages = [{"role": "system", "content": "أنت كوين، العقل المدبر لمنصة تعليمية عربية. تساعد في صياغة محتوى تعليمي قصير وواضح."}] messages.extend(history) messages.append({"role": "user", "content": prompt}) text_prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) inputs = tokenizer([text_prompt], return_tensors="pt").to(DEVICE) with torch.no_grad(): out_ids = model.generate(**inputs, max_new_tokens=200, temperature=0.5, do_sample=True, repetition_penalty=1.15, pad_token_id=tokenizer.eos_token_id) out_ids = [o[len(i):] for i, o in zip(inputs.input_ids, out_ids)] response = tokenizer.batch_decode(out_ids, skip_special_tokens=True)[0].strip() gc.collect() # تخفيف ذروة الذاكرة بعد كل توليد new_history = history + [{"role": "user", "content": prompt}, {"role": "assistant", "content": response}] return response, new_history def generate_image_via_media(prompt): try: client = Client(MEDIA_SPACE_URL) return client.predict(prompt, RADAR_API_KEY, api_name="/generate_image") except Exception as e: print(f"⚠️ خطأ مساحة الوسائط: {e}") return None # ════════════════════════════════════════════════════════════════════ # 🎙️ خط معالجة الصوت المتقدم (4 مراحل) — كل مرحلة محمية بشكل مستقل # ════════════════════════════════════════════════════════════════════ SKIP_HEAVY_AUDIO_FILTERS = os.environ.get("SKIP_HEAVY_AUDIO_FILTERS", "").strip().lower() in ("1", "true", "yes") if SKIP_HEAVY_AUDIO_FILTERS: print("🧪 وضع التشخيص: تخطّي DeepFilterNet وMetricGAN+ (مفعّل عبر SKIP_HEAVY_AUDIO_FILTERS)") def normalize_audio_pydub(input_path): try: from pydub import AudioSegment, effects sound = AudioSegment.from_file(input_path) normalized = effects.normalize(sound) output_path = "/tmp/step1_normalized.wav" normalized.export(output_path, format="wav") return output_path except Exception as e: print(f"⚠️ فشلت خطوة تطبيع الصوت: {e}") return input_path _df_model, _df_state = None, None def denoise_with_deepfilternet(input_path): global _df_model, _df_state try: from df.enhance import enhance as df_enhance, init_df, load_audio as df_load_audio, save_audio as df_save_audio if _df_model is None: print("⏳ تحميل DeepFilterNet...") _df_model, _df_state, _ = init_df() audio, _ = df_load_audio(input_path, sr=_df_state.sr()) enhanced = df_enhance(_df_model, _df_state, audio) output_path = "/tmp/step2_denoised.wav" df_save_audio(output_path, enhanced, _df_state.sr()) return output_path except Exception as e: print(f"⚠️ فشلت خطوة تصفية الضوضاء: {e}") return input_path _metricgan_model = None def isolate_speaker_metricgan(input_path): global _metricgan_model try: import torchaudio from speechbrain.inference.enhancement import SpectralMaskEnhancement if _metricgan_model is None: print("⏳ تحميل MetricGAN+...") _metricgan_model = SpectralMaskEnhancement.from_hparams( source="speechbrain/metricgan-plus-voicebank", savedir="/tmp/pretrained_metricgan", ) noisy = _metricgan_model.load_audio(input_path).unsqueeze(0) enhanced = _metricgan_model.enhance_batch(noisy, lengths=torch.tensor([1.0])) output_path = "/tmp/step3_isolated.wav" torchaudio.save(output_path, enhanced.cpu(), 16000) return output_path except Exception as e: print(f"⚠️ فشلت خطوة عزل المتحدث: {e}") return input_path def preprocess_student_audio(raw_audio_path): t0 = time.time() step1 = normalize_audio_pydub(raw_audio_path) print(f"⏱️ تطبيع الصوت (pydub): {time.time() - t0:.1f}s") if SKIP_HEAVY_AUDIO_FILTERS: return step1 # ⚠️ بناءً على طلبك: تخطّي DeepFilterNet نهائياً من المسار الفعلي حالياً # (الدالة denoise_with_deepfilternet تبقى في الكود لاستخدامها لاحقاً # لو احتجتها مستقبلاً، فقط لا تُستدعى الآن) — فقط فلتر العزل (MetricGAN+). t2 = time.time() step3 = isolate_speaker_metricgan(step1) print(f"⏱️ عزل المتحدث (MetricGAN+): {time.time() - t2:.1f}s") return step3 _whisper_pipe = None def load_whisper(): global _whisper_pipe if _whisper_pipe is not None: return _whisper_pipe from transformers import pipeline print("⏳ تحميل Whisper-medium...") _whisper_pipe = pipeline("automatic-speech-recognition", model=WHISPER_MODEL, device=DEVICE, chunk_length_s=30) return _whisper_pipe # ════════════════════════════════════════════════════════════════════ # الرادار — الصوت فقط من هنا، دائماً # ════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════ # 🧪 Edge-TTS محلي مباشر داخل هذه المساحة — بدل الاتصال بالرادار، كتجربة # لتشخيص/حل مشكلة التعليق الطويل (كانت تظهر "processing 186s" بدون نتيجة). # 🔇 الرادار حُذف نهائياً من هذا التطبيق بناءً على طلبك — لا يوجد أي # اتصال به على الإطلاق بعد الآن. الصوت 100% من Edge-TTS المحلي فقط. # ════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════ # 🔧 إصلاح جذري: حلقة asyncio الدائمة في خيط منفصل (_start_edge_loop) # كانت السبب الفعلي والمستمر لخطأ "ValueError: Invalid file descriptor: -1" # — وقد تأكدنا أنه ظهر حتى في المساحة القديمة (الرادار) بنفس النمط، فهو # عيب في النمط نفسه لا في مساحة معيّنة. الحل: asyncio.run() لكل استدعاء — # تنشئ حلقة جديدة وتُنظّفها بالكامل (إلغاء المهام + إغلاق async generators # + إغلاق الحلقة) في كل مرة، فلا يبقى أي كائن حلقة معلّق يُسبّب هذا الخطأ # عند جمع القمامة لاحقاً. # ════════════════════════════════════════════════════════════════════ def call_edge_tts_local(text, voice): filepath = f"/tmp/edge_{int(time.time() * 1000)}.mp3" async def _task(): communicate = edge_tts.Communicate(text.strip(), voice) await communicate.save(filepath) try: asyncio.run(_task()) return filepath except Exception as e: print(f"⚠️ خطأ Edge-TTS المحلي: {e}") return None # ════════════════════════════════════════════════════════════════════ # الخزنة: الدروس + ملف الطالب # ════════════════════════════════════════════════════════════════════ def clean_subject_for_path(subject): """يحذف الإيموجي البادئ من اسم المادة (🧮 الرياضيات → الرياضيات) لمطابقة أسماء المجلدات الحقيقية في الخزنة، التي لا تحتوي إيموجي.""" if not subject: return subject parts = subject.strip().split(" ", 1) return parts[-1].strip() if len(parts) > 1 else subject.strip() def list_lessons_in_vault(grade, semester, subject): try: # ⚠️ الترتيب الصحيح المؤكَّد من الخزنة الفعلية: المادة/الفصل/الصف clean_subject = clean_subject_for_path(subject) folder_path = f"Curriculum_Core/{clean_subject}/{semester}/{grade}/" all_files = _hf_api.list_repo_files(repo_id=VAULT_REPO_ID, repo_type="dataset") lesson_files = [f for f in all_files if f.startswith(folder_path) and f.lower().endswith(('.txt', '.md', '.json'))] choices = [] for f in sorted(lesson_files): display_name = re.sub(r'\.(txt|md|json)$', '', os.path.basename(f), flags=re.IGNORECASE) choices.append((display_name, f)) return choices except Exception as e: print(f"⚠️ فشل سرد الدروس: {e}") return [] def load_lesson_content(lesson_path): if not lesson_path: return None try: local_path = hf_hub_download(repo_id=VAULT_REPO_ID, filename=lesson_path, repo_type="dataset") with open(local_path, "r", encoding="utf-8") as f: content = f.read() return content[:MAX_LESSON_CHARS] if len(content) > MAX_LESSON_CHARS else content except Exception as e: print(f"⚠️ فشل تحميل الدرس: {e}") return None def save_student_profile(grade, semester, subject, lesson_path, user_name, track=None): if not HF_TOKEN: return try: profile = { "user_name": user_name, "grade": grade, "semester": semester, "subject": subject, "lesson": lesson_path, "track": track, "last_updated": time.strftime("%Y-%m-%d %H:%M:%S") } _hf_api.upload_file( path_or_fileobj=json.dumps(profile, ensure_ascii=False, indent=2).encode("utf-8"), path_in_repo=f"Student_Hub/User_{user_name}/profile.json", repo_id=VAULT_REPO_ID, repo_type="dataset", token=HF_TOKEN ) except Exception as e: print(f"⚠️ خطأ في الحفظ: {e}") def upload_lesson_to_vault(file_obj, lesson_number, lesson_title, subject, semester, grade): if not HF_TOKEN: return "⚠️ لا يوجد HF_TOKEN في أسرار هذه المساحة، لا يمكن الرفع." if file_obj is None: return "⚠️ اختر ملفاً أولاً." match = re.search(r'\d+', str(lesson_number or "")) if not match: return "⚠️ أدخل رقم الدرس (مثل: 1)." try: clean_subject = clean_subject_for_path(subject) num = int(match.group()) title = (lesson_title or "").strip() or "بدون_عنوان" ext = os.path.splitext(file_obj.name)[1] or ".txt" path_in_repo = f"Curriculum_Core/{clean_subject}/{semester}/{grade}/{num:02d}_{title}{ext}" _hf_api.upload_file( path_or_fileobj=file_obj.name, path_in_repo=path_in_repo, repo_id=VAULT_REPO_ID, repo_type="dataset", token=HF_TOKEN, ) return f"✅ تم رفع الدرس إلى: {path_in_repo}" except Exception as e: return f"⚠️ فشل الرفع: {e}" # ════════════════════════════════════════════════════════════════════ # الصفوف/الفصول/المواد # ════════════════════════════════════════════════════════════════════ GRADES = [ "الروضة", "الصف الأول", "الصف الثاني", "الصف الثالث", "الصف الرابع", "الصف الخامس", "الصف السادس", "الصف السابع", "الصف الثامن", "الصف التاسع", "الصف العاشر", "الصف الحادي عشر", "الصف الثاني عشر (التوجيهي)" ] SEMESTERS = ["الفصل الأول", "الفصل الثاني"] DEFAULT_SUBJECTS = ["🧮 الرياضيات", "🔬 العلوم", "📗 اللغة العربية", "🕌 التربية الإسلامية", "🔤 اللغة الإنجليزية"] # ════════════════════════════════════════════════════════════════════ # دالة المحادثة الرئيسية لغرفة الصف # ════════════════════════════════════════════════════════════════════ def teacher_chat(audio_mic, direct_text, subject, grade, semester, lesson_path, track, student_name, chat_history_state, teacher_prefs, active_idx): t_start = time.time() if direct_text and direct_text.strip(): user_text = direct_text.strip() else: if audio_mic is None: return chat_history_state, None, "🎤 سجّل سؤالك أو استخدم أحد أدواتي" try: t0 = time.time() processed_audio = preprocess_student_audio(audio_mic) whisper = load_whisper() result = whisper(processed_audio, generate_kwargs={"language": "arabic"}) user_text = result["text"].strip() print(f"⏱️ المرحلة الصوتية كاملة (تطبيع+فلاتر+Whisper): {time.time() - t0:.1f}s") except Exception as e: return chat_history_state, None, f"⚠️ خطأ: {e}" if not user_text: return chat_history_state, None, "❌ لم يتم التعرف على كلام" print(f"🗣️ {student_name}: {user_text}") lesson_content = load_lesson_content(lesson_path) is_science = subject in ("🧮 الرياضيات", "🔬 العلوم") if is_science: t0 = time.time() phi_content = ask_science_teacher_via_controller(user_text, "teach", chat_history_state) print(f"⏱️ استدعاء Controller/Phi: {time.time() - t0:.1f}s") t0 = time.time() if phi_content: cleaned_response, new_history = deliver_via_qwen(user_text, phi_content, chat_history_state) else: cleaned_response, new_history = ask_teacher(user_text, chat_history_state) print(f"⏱️ توليد كوين (مسار علمي): {time.time() - t0:.1f}s") else: t0 = time.time() student_name_clean = (student_name or "الطالب").strip() context_str = f"مادة {subject} - {semester}" + (f" - تخصص {track}" if track else "") system_prompt = ( f"أنت الأستاذ عبود، معلم خبير ودود جداً. تتحدث الآن مع طالبك {student_name_clean}. السياق: {context_str}.\n" f"- خاطب {student_name_clean} باسمه من وقت لآخر بشكل طبيعي.\n" "- إذا كانت تحية، رد بتحية ودودة قصيرة فقط.\n" "- إذا كان سؤالاً تعليمياً: أجب بإيجاز (30 كلمة)، خطوات، سؤال ختامي.\n" "- إذا وردت معادلة LaTeX بين $$، فسّرها بوضوح." ) if lesson_content: system_prompt += f"\n\n📖 الدرس المرجعي (مساعد فقط):\n```\n{lesson_content}\n```" messages = [{"role": "system", "content": system_prompt}] + chat_history_state + [{"role": "user", "content": user_text}] tokenizer, model = load_qwen_lazy() text_prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) inputs = tokenizer([text_prompt], return_tensors="pt").to(DEVICE) with torch.no_grad(): out_ids = model.generate(**inputs, max_new_tokens=200, temperature=0.4, do_sample=True, repetition_penalty=1.15, pad_token_id=tokenizer.eos_token_id) out_ids = [o[len(i):] for i, o in zip(inputs.input_ids, out_ids)] cleaned_response = tokenizer.batch_decode(out_ids, skip_special_tokens=True)[0].strip() gc.collect() # تخفيف ذروة الذاكرة بعد كل توليد if not cleaned_response.endswith(('.', '؟', '!')): cleaned_response += '.' new_history = chat_history_state + [{"role": "user", "content": user_text}, {"role": "assistant", "content": cleaned_response}] print(f"⏱️ توليد كوين (مسار أدبي/لغات): {time.time() - t0:.1f}s") save_student_profile(grade, semester, subject, lesson_path, student_name, track) # 🎙️ صوت "المحاور" للمحادثة العادية حالياً (أصوات الشرح/الأسئلة # ستُفعَّل تلقائياً عند بناء منطق الحصة المقسّمة لاحقاً) try: voice = teacher_prefs[active_idx]["محاور"] except Exception: voice = "ar-JO-TaimNeural" t0 = time.time() audio_path = call_edge_tts_local(cleaned_response, voice) # 🧪 محلي مباشر بدل الرادار (للتجربة) print(f"⏱️ توليد الصوت (Edge-TTS محلي): {time.time() - t0:.1f}s") print(f"⏱️ الوقت الإجمالي للدورة كاملة: {time.time() - t_start:.1f}s") status = "✅ تم الرد" if audio_path else "⚠️ فشل الصوت" return new_history, audio_path, status # ════════════════════════════════════════════════════════════════════ # أدواتي: آلة حاسبة + كاميرا OCR + لوحة مفاتيح بديلة # ════════════════════════════════════════════════════════════════════ SYMPY_LOCALS = {"sin": sp.sin, "cos": sp.cos, "tan": sp.tan, "sqrt": sp.sqrt, "pi": sp.pi} CALC_BUTTONS = [ [("7", "7"), ("8", "8"), ("9", "9"), ("÷", "/"), ("√", "sqrt(")], [("4", "4"), ("5", "5"), ("6", "6"), ("×", "*"), ("^", "**")], [("1", "1"), ("2", "2"), ("3", "3"), ("-", "-"), ("π", "pi")], [("0", "0"), (".", "."), ("(", "("), (")", ")"), ("+", "+")], [("sin", "sin("), ("cos", "cos("), ("tan", "tan("), ("C", "__CLEAR__"), ("⌫", "__BACK__")], ] def calc_append(current, token): return (current or "") + token def calc_clear(): return "" def calc_backspace(current): return (current or "")[:-1] def calc_evaluate(expr): if not expr or not expr.strip(): return expr, "⚠️ أدخل عبارة أولاً" try: return str(sp.sympify(expr, locals=SYMPY_LOCALS).evalf()), "✅ تم الحساب" except Exception as e: return expr, f"⚠️ تعذر الحساب: {e}" def calc_to_latex_question(expr): if not expr or not expr.strip(): return "" try: return f"اشرح لي خطوات حل هذه المسألة: $$ {sp.latex(sp.sympify(expr, locals=SYMPY_LOCALS))} $$" except Exception as e: return f"اشرح لي خطوات حل هذه المسألة: {expr}" _ocr_processor, _ocr_model = None, None def load_ocr(): global _ocr_processor, _ocr_model if _ocr_model is not None: return _ocr_processor, _ocr_model print("⏳ تحميل نموذج قراءة الصور العربي (TrOCR)...") _ocr_processor = TrOCRProcessor.from_pretrained(OCR_MODEL_ID) _ocr_model = VisionEncoderDecoderModel.from_pretrained(OCR_MODEL_ID) return _ocr_processor, _ocr_model def read_image_text(image_path): if not image_path: return "", "⚠️ لم يتم رفع صورة" try: processor, model = load_ocr() image = Image.open(image_path).convert("RGB") pixel_values = processor(images=image, return_tensors="pt").pixel_values generated_ids = model.generate(pixel_values, max_new_tokens=200) text = processor.batch_decode(generated_ids, skip_special_tokens=True)[0] return text, "✅ تم استخراج النص" except Exception as e: return "", f"⚠️ خطأ في قراءة الصورة: {e}" # ════════════════════════════════════════════════════════════════════ # الواجهة # ════════════════════════════════════════════════════════════════════ with gr.Blocks(title="My Teacher Lesson", theme=gr.themes.Soft()) as demo: theme_html = gr.HTML(apply_theme("🌞 نهاري")) gr.Markdown("# 🎓 المنصة التعليمية الذكية الموحدة") teacher_prefs_state = gr.State([dict(t) for t in DEFAULT_TEACHER_PREFS]) active_teacher_index_state = gr.State(0) chat_history_state = gr.State([]) subject_choices_state = gr.State(list(DEFAULT_SUBJECTS)) with gr.Tabs(): # ────────────────────────────────────────────────────────── # تبويب 1: غرفة الصف (مدمجة بالكامل من ملف Chat) # ────────────────────────────────────────────────────────── with gr.Tab("🏫 غرفة الصف"): with gr.Row(): with gr.Column(scale=2): with gr.Row(): student_name_input = gr.Textbox(label="👤 اسم الطالب", value="Divid") grade_dropdown = gr.Dropdown(choices=GRADES, value="الصف العاشر", label="📆 الصف") with gr.Row(): semester_dropdown = gr.Dropdown(choices=SEMESTERS, value="الفصل الأول", label="📅 الفصل") track_dropdown = gr.Dropdown(choices=[], value=None, label="🎯 التخصص (توجيهي)", visible=False, allow_custom_value=True) with gr.Row(visible=False) as track_row: new_track_input = gr.Textbox(label="➕ تخصص جديد") add_track_btn = gr.Button("💾 حفظ", size="sm") with gr.Row(): subject_dropdown = gr.Dropdown(choices=DEFAULT_SUBJECTS, value=DEFAULT_SUBJECTS[0], label="📚 المادة") add_subject_btn = gr.Button("➕ إضافة مواد أخرى", size="sm") with gr.Row(visible=False) as new_subject_row: new_subject_input = gr.Textbox(label="✍️ اسم المادة الجديدة") confirm_subject_btn = gr.Button("💾 حفظ", size="sm") with gr.Row(): lesson_dropdown = gr.Dropdown(choices=[], label="📖 الدرس (من الخزنة)") refresh_lessons_btn = gr.Button("🔄", size="sm") with gr.Accordion("📤 رفع درس جديد إلى الخزنة", open=False): upload_lesson_number = gr.Textbox(label="رقم الدرس", placeholder="مثال: 1") upload_lesson_title = gr.Textbox(label="عنوان الدرس", placeholder="مثال: القيمة المنزلية للرقم") upload_lesson_file = gr.File(label="اختر ملف الدرس من جهازك") upload_lesson_btn = gr.Button("💾 حفظ في الخزنة", variant="primary") upload_lesson_status = gr.Textbox(label="حالة الرفع", interactive=False) chatbot_display = gr.Chatbot(label="الحوار", height=400) mic_input = gr.Audio(label="🎤 سؤالك", type="filepath", sources=["microphone"]) send_btn = gr.Button("🚀 إرسال", variant="primary") with gr.Accordion("🛠️ أدواتي", open=False): with gr.Tab("🧮 الآلة الحاسبة العلمية"): calc_display = gr.Textbox(label="العبارة", value="") for row in CALC_BUTTONS: with gr.Row(): for label, token in row: b = gr.Button(label, size="sm") if token == "__CLEAR__": b.click(fn=calc_clear, outputs=[calc_display], api_name=False) elif token == "__BACK__": b.click(fn=calc_backspace, inputs=[calc_display], outputs=[calc_display], api_name=False) else: b.click(fn=lambda cur, t=token: calc_append(cur, t), inputs=[calc_display], outputs=[calc_display], api_name=False) with gr.Row(): calc_eq_btn = gr.Button("🟰 حساب", size="sm") calc_send_btn = gr.Button("📤 حوّل لـ LaTeX وأرسل", size="sm") calc_status = gr.Textbox(label="الحالة", interactive=False) calc_pending_question = gr.Textbox(visible=False) calc_eq_btn.click(fn=calc_evaluate, inputs=[calc_display], outputs=[calc_display, calc_status], api_name=False) with gr.Tab("📷 الكاميرا وقراءة الصور"): camera_input = gr.Image(label="صوّر/ارفع ورقة العمل", type="filepath", sources=["upload", "webcam"]) ocr_btn = gr.Button("🔍 اقرأ النص") ocr_text_out = gr.Textbox(label="النص المستخرج", lines=3) ocr_status = gr.Textbox(label="الحالة", interactive=False) ocr_send_btn = gr.Button("➡️ أرسل للأستاذ", variant="primary") ocr_btn.click(fn=read_image_text, inputs=[camera_input], outputs=[ocr_text_out, ocr_status], api_name=False) with gr.Tab("⌨️ لوحة المفاتيح البديلة"): emergency_text_in = gr.Textbox(label="اكتب سؤالك هنا", lines=3) emergency_send_btn = gr.Button("➡️ أرسل للأستاذ", variant="primary") with gr.Column(scale=1): audio_output = gr.Audio(label="🔊 الرد", type="filepath", autoplay=True) status_output = gr.Textbox(label="الحالة") # ────────────────────────────────────────────────────────── # تبويب 2: الإعدادات والتفضيلات # ────────────────────────────────────────────────────────── with gr.Tab("⚙️ الإعدادات والتفضيلات"): gr.Markdown("### 🎨 مظهر المنصة") theme_radio = gr.Radio(choices=list(THEME_CSS.keys()), value="🌞 نهاري", label="اختر المظهر") gr.Markdown("---\n### 👨‍🏫 عدد المعلمين وأصوات الأدوار") teacher_count_radio = gr.Radio(choices=["2", "3"], value="2", label="عدد المعلمين المتاحين") active_teacher_radio = gr.Radio(choices=["المعلم الأول", "المعلمة الثانية", "المعلم الثالث"], value="المعلم الأول", label="المعلم النشط حالياً في غرفة الصف") teacher_setting_rows = [] for idx in range(3): with gr.Row(visible=(idx < 2)) as row: name_box = gr.Textbox(label=f"اسم المعلم {idx + 1}", value=DEFAULT_TEACHER_PREFS[idx]["name"]) voice_dd_map = {} for role in VOICE_ROLES: voice_dd_map[role] = gr.Dropdown(choices=MICROSOFT_VOICES, value=DEFAULT_TEACHER_PREFS[idx][role], label=f"صوت {role}") teacher_setting_rows.append((row, name_box, voice_dd_map)) save_settings_btn = gr.Button("💾 حفظ التفضيلات", variant="primary") settings_status = gr.Textbox(label="حالة الحفظ", interactive=False) def toggle_teacher_count(count): n = int(count) return [gr.update(visible=(i < n)) for i in range(3)] teacher_count_radio.change( fn=toggle_teacher_count, inputs=[teacher_count_radio], outputs=[r[0] for r in teacher_setting_rows], api_name=False ) def save_all_settings(*args): # args = name1, v1_محاور, v1_شرح, v1_أسئلة, name2, ..., name3, ... prefs = [] i = 0 names_voices = list(args) for t_idx in range(3): name = names_voices[i]; i += 1 roles = {} for role in VOICE_ROLES: roles[role] = names_voices[i]; i += 1 prefs.append({"name": name, **roles}) return prefs, "✅ تم حفظ التفضيلات" all_setting_inputs = [] for row, name_box, voice_dd_map in teacher_setting_rows: all_setting_inputs.append(name_box) for role in VOICE_ROLES: all_setting_inputs.append(voice_dd_map[role]) save_settings_btn.click( fn=save_all_settings, inputs=all_setting_inputs, outputs=[teacher_prefs_state, settings_status], api_name=False ) def set_active_teacher(choice): mapping = {"المعلم الأول": 0, "المعلمة الثانية": 1, "المعلم الثالث": 2} return mapping.get(choice, 0) active_teacher_radio.change(fn=set_active_teacher, inputs=[active_teacher_radio], outputs=[active_teacher_index_state], api_name=False) theme_radio.change(fn=apply_theme, inputs=[theme_radio], outputs=[theme_html], api_name=False) # ────────────────────────────────────────────────────────── # التبويبات 3-6: حجز مكان — سنبنيها واحدة تلو الأخرى # ────────────────────────────────────────────────────────── with gr.Tab("🗂️ خطط الدروس والتقسيم"): gr.Markdown("🚧 **قيد التطوير.** سيُبنى لاحقاً بالتعاون مع كوين وPhi.") with gr.Tab("📊 المستويات وتحليل الفجوة التعليمية"): gr.Markdown("🚧 **قيد التطوير.**") with gr.Tab("💬 علاقة الطالب والأستاذ"): gr.Markdown("🚧 **قيد التطوير.**") with gr.Tab("🖼️ أرشيف وإدارة الوسائط"): gr.Markdown("🚧 **قيد التطوير.**") # ──── ربط الأحداث: غرفة الصف ──── def toggle_track(grade): if "التوجيهي" in grade: return gr.update(visible=True), gr.update(visible=True), gr.update(visible=True) return gr.update(visible=False, value=None), gr.update(visible=False), gr.update(visible=False) grade_dropdown.change(fn=toggle_track, inputs=[grade_dropdown], outputs=[track_dropdown, track_row, add_track_btn], api_name=False) add_subject_btn.click(fn=lambda: gr.update(visible=True), outputs=[new_subject_row], api_name=False) def confirm_new_subject(new_subject, choices): new_subject = (new_subject or "").strip() if not new_subject: return gr.update(), choices, gr.update(visible=False), "" if new_subject not in choices: choices = choices + [new_subject] return gr.update(choices=choices, value=new_subject), choices, gr.update(visible=False), "" confirm_subject_btn.click(fn=confirm_new_subject, inputs=[new_subject_input, subject_choices_state], outputs=[subject_dropdown, subject_choices_state, new_subject_row, new_subject_input], api_name=False) def refresh_lessons(grade, semester, subject): lessons = list_lessons_in_vault(grade, semester, subject) return gr.update(choices=lessons, value=(lessons[0][1] if lessons else None)) grade_dropdown.change(fn=refresh_lessons, inputs=[grade_dropdown, semester_dropdown, subject_dropdown], outputs=[lesson_dropdown], api_name=False) semester_dropdown.change(fn=refresh_lessons, inputs=[grade_dropdown, semester_dropdown, subject_dropdown], outputs=[lesson_dropdown], api_name=False) subject_dropdown.change(fn=refresh_lessons, inputs=[grade_dropdown, semester_dropdown, subject_dropdown], outputs=[lesson_dropdown], api_name=False) refresh_lessons_btn.click(fn=refresh_lessons, inputs=[grade_dropdown, semester_dropdown, subject_dropdown], outputs=[lesson_dropdown], api_name=False) demo.load(fn=refresh_lessons, inputs=[grade_dropdown, semester_dropdown, subject_dropdown], outputs=[lesson_dropdown]) upload_lesson_btn.click( fn=upload_lesson_to_vault, inputs=[upload_lesson_file, upload_lesson_number, upload_lesson_title, subject_dropdown, semester_dropdown, grade_dropdown], outputs=[upload_lesson_status], api_name=False, ).then( fn=refresh_lessons, inputs=[grade_dropdown, semester_dropdown, subject_dropdown], outputs=[lesson_dropdown], api_name=False ) def sanitize_history_for_chatbot(history): """🛡️ تنظيف دفاعي نهائي وصارم: أياً كان مصدر العطل (Phi، كوين، تسلسل غير متوقع)، هذا يضمن أن كل عنصر يصل لـ Chatbot هو dict نظيف 100% بمفتاحي role/content فقط وقيم نصية صافية — يمنع تكرار خطأ 'Data incompatible with messages format' نهائياً.""" clean = [] for msg in (history or []): if isinstance(msg, dict) and "role" in msg and "content" in msg: role = str(msg["role"]).strip() content = str(msg["content"]).strip() if role and content: clean.append({"role": role, "content": content}) return clean def process_and_display(audio_mic, direct_text, subject, grade, semester, lesson_path, track, student_name, history_state, teacher_prefs, active_idx): new_history, audio, status = teacher_chat(audio_mic, direct_text, subject, grade, semester, lesson_path, track, student_name, history_state, teacher_prefs, active_idx) clean_history = sanitize_history_for_chatbot(new_history) return clean_history, clean_history, audio, status, None, "" common_inputs_tail = [subject_dropdown, grade_dropdown, semester_dropdown, lesson_dropdown, track_dropdown, student_name_input, chat_history_state, teacher_prefs_state, active_teacher_index_state] send_btn.click( fn=process_and_display, inputs=[mic_input, calc_pending_question] + common_inputs_tail, outputs=[chat_history_state, chatbot_display, audio_output, status_output, mic_input, calc_pending_question], api_name=False, ) calc_send_btn.click(fn=calc_to_latex_question, inputs=[calc_display], outputs=[calc_pending_question], api_name=False).then( fn=process_and_display, inputs=[mic_input, calc_pending_question] + common_inputs_tail, outputs=[chat_history_state, chatbot_display, audio_output, status_output, mic_input, calc_pending_question], api_name=False, ) ocr_send_btn.click( fn=process_and_display, inputs=[mic_input, ocr_text_out] + common_inputs_tail, outputs=[chat_history_state, chatbot_display, audio_output, status_output, mic_input, ocr_text_out], api_name=False, ) emergency_send_btn.click( fn=process_and_display, inputs=[mic_input, emergency_text_in] + common_inputs_tail, outputs=[chat_history_state, chatbot_display, audio_output, status_output, mic_input, emergency_text_in], api_name=False, ) if __name__ == "__main__": demo.launch(server_name="0.0.0.0", server_port=7860)