| """ |
| دورة القانون – Hugging Face Space |
| النسخة العربية الكاملة |
| Compatible avec le Docker HF (gradio 4.44.1 bundled + Python 3.13). |
| """ |
| import os, json, time, tempfile, random, threading, glob |
| import sys |
| try: |
| import pyaudioop |
| sys.modules["audioop"] = pyaudioop |
| except ImportError: |
| pass |
|
|
| import gradio as gr |
|
|
| try: |
| import gradio_client.utils as _gcu |
| import gradio.blocks as _gb |
| _orig = _gcu.json_schema_to_python_type |
| def _safe(schema): |
| try: return _orig(schema) |
| except Exception: return "Any" |
| _gcu.json_schema_to_python_type = _safe |
| _gb.client_utils.json_schema_to_python_type = _safe |
| except Exception: |
| pass |
|
|
| try: |
| from llama_cpp import Llama |
| _LLAMA_AVAILABLE = True |
| except ImportError: |
| _LLAMA_AVAILABLE = False |
|
|
| from gtts import gTTS |
| from huggingface_hub import hf_hub_download |
|
|
| try: |
| from llama_cpp import Llama |
| _LLAMA_OK = True |
| except ImportError: |
| _LLAMA_OK = False |
|
|
| |
| _llm = None |
| _llm_lock = threading.Lock() |
|
|
| def download_model(): |
| model_path = "/app/model.gguf" |
| if os.path.exists(model_path): |
| print("[law-app] النموذج موجود مسبقاً.") |
| return model_path |
| print("[law-app] جارٍ تحميل النموذج...") |
| try: |
| path = hf_hub_download( |
| repo_id="Qwen/Qwen2.5-0.5B-Instruct-GGUF", |
| filename="qwen2.5-0.5b-instruct-q4_k_m.gguf", |
| local_dir="/app", |
| local_dir_use_symlinks=False, |
| ) |
| if path != model_path: |
| os.rename(path, model_path) |
| print("[law-app] اكتمل التحميل.") |
| return model_path |
| except Exception as e: |
| print(f"[law-app] خطأ في التحميل: {e}") |
| return None |
|
|
| def get_llm(): |
| global _llm |
| if _llm is not None: |
| return _llm |
| if not _LLAMA_AVAILABLE: |
| return None |
| with _llm_lock: |
| if _llm is not None: |
| return _llm |
| gguf_files = glob.glob("/app/*.gguf") + glob.glob("*.gguf") |
| if not gguf_files: |
| print("[law-app] لم يُعثر على ملف .gguf – الذكاء الاصطناعي معطّل.") |
| return None |
| model_path = gguf_files[0] |
| print(f"[law-app] تحميل النموذج: {model_path}") |
| _llm = Llama(model_path=model_path, n_ctx=512, n_threads=2, |
| n_gpu_layers=int(os.getenv("N_GPU_LAYERS", "-1")), verbose=False) |
| print("[law-app] النموذج جاهز.") |
| return _llm |
| LESSONS = [ |
|
|
| { |
| "title": "الدرس الأول — الأعداد من 0 إلى 999 (علب الحلوى السحرية)", |
| "text": """ |
| مرحباً بك يا بطل! تخيل أن لدينا ثلاث علب نضع فيها قطع الحلوى السحرية: |
| 1. علبة الوحدات (و): نضع فيها حبات الحلوى الفردية (من 1 إلى 9). |
| 2. علبة العشرات (ع): نضع فيها علباً صغيرة، كل علبة بها 10 حبات. |
| 3. علبة المئات (م): نضع فيها علباً كبيرة، كل علبة بها 100 حبة. |
| |
| أولاً: كيف نقرأ ونكتب العدد؟ (الصفحة 17) |
| لو كان لدينا العدد (659): |
| - ننظر للعلبة الكبيرة أولاً (المئات): لدينا 6 علب من فئة 100، فنقول: "ستمائة". |
| - ثم ننظر لعلبة الوحدات: لدينا 9 حبات فردية، فنقول: "وتسعة". |
| - ثم علبة العشرات: لدينا 5 علب من فئة 10، فنقول: "وخمسون". |
| نقرأ العدد كاملاً هكذا: "سِتُّمِائَةٍ وَتِسْعَةٌ وَخَمْسُونَ". |
| |
| ثانياً: كيف نفكك العدد؟ (الصفحة 17) |
| التفكيك يعني أن نفتح العلب ونرى كم حبة حلوى بداخلها بالكامل: |
| - التفكيك بالجمع: نأخذ كل منزلة ونكتب قيمتها: |
| 659 = 600 + 50 + 9 |
| - التفكيك بالضرب والجمع: نكتب كم علبة لدينا في كل منزلة: |
| 659 = (6 × 100) + (5 × 10) + 9 |
| """, |
| "questions": [ |
| {"q": "اكتب العدد (أربعمائة واثنان وثلاثون) بالأرقام ثم فككه تفكيكاً بالجمع.", |
| "hint": "العدد يكتب 432، وتفكيكه هو: 432 = 400 + 30 + 2 (انظر الصفحة 17)."}, |
| {"q": "ما هي مرتبة الرقم 5 في العدد 527؟ وكم حبة حلوى يمثل؟", |
| "hint": "مرتبته هي المئات (م)، ويمثل 500 حبة حلوى سحرية (انظر الصفحة 17)."}, |
| {"q": "افتح علب العدد 854 وفككه بطريقة الضرب والجمع.", |
| "hint": "التفكيك هو: 854 = (8 × 100) + (5 × 10) + 4 (انظر الصفحة 25)."} |
| ] |
| }, |
| { |
| "title": "الدرس الثاني — الجمع والطرح (استراتيجيات الحساب الذكي والضفدع القفاز)", |
| "text": """ |
| مرحبا يا ذكي! هناك طرق ممتعة تساعدك على الجمع والطرح بسرعة في ذهنك: |
| |
| أولاً: الجمع بالتفكيك والإتمام (الصفحة 14) |
| لو أردنا حساب: 54 + 7 |
| - العدد 54 قريب جداً من الـ 60 (يحتاج فقط إلى 6 خطوات ليصل إليها). |
| - نذهب للـ 7 ونأخذ منها 6 خطوات، فتبقى الـ 7 عبارة عن (6 + 1). |
| - الآن نجمع بسهولة: 54 + 6 = 60، ثم نضيف الـ 1 المتبقي: 60 + 1 = 61. |
| - إذن: 54 + 7 = 61. |
| |
| ثانياً: الطرح بالشريط العددي وقفزة الضفدع (الصفحة 14) |
| لو أردنا حساب: 54 - 7 |
| - تخيل أنك ضفدع يقف عند الرقم 54، وتريد القفز للخلف 7 خطوات. |
| - أولاً، اقفز قفزة سهلة للخلف بـ 4 خطوات لتصل للرقم المستدير السهل 50. |
| - بقيت لك 3 خطوات لتكمل قفزاتك السبع؛ اقفزها للخلف من الـ 50 لتصل إلى 47. |
| - إذن: 54 - 7 = 47. |
| |
| ثالثاً: الجمع العمودي بالاحتفاظ (الصفحة 28) |
| لو أردنا حساب: 191 + 547 |
| - نرتب الأرقام بدقة: الوحدات تحت الوحدات، العشرات تحت العشرات، والمئات تحت المئات. |
| - نجمع الوحدات: 1 + 7 = 8. |
| - نجمع العشرات: 9 + 4 = 13. لا نكتب 13 كاملة! نكتب 3 ونضع الـ 1 (كضيف) فوق المئات. |
| - نجمع المئات مع الضيف: 1 (الضيف) + 1 + 5 = 7. |
| - النتيجة النهائية هي: 738. |
| """, |
| "questions": [ |
| {"q": "احسب بذهنك مستعملاً التفكيك والإتمام إلى العشرة الموالية: 59 + 3.", |
| "hint": "نأخذ 1 من الـ 3 لنتمم 59 إلى 60، ويبقى 2. النتيجة هي 60 + 2 = 62 (انظر الصفحة 14)."}, |
| {"q": "باستخدام قفزة الضفدع للخلف على الشريط العددي، احسب: 92 - 23.", |
| "hint": "اطرح 20 خطوة لتصل إلى 72، ثم اطرح 3 خطوات لتصل إلى 69. النتيجة هي 69 (انظر الصفحة 20)."}, |
| {"q": "أوجد نتيجة العملية التالية عمودياً مع الانتباه للضيف المحتفظ به: 325 + 12.", |
| "hint": "نرتب الوحدات والعشرات بدقة: 5+2=7 في الوحدات، 2+1=3 في العشرات، و3 في المئات. النتيجة هي 337 (انظر الصفحة 24)."} |
| ] |
| }, |
| { |
| "title": "الدرس الثالث — الهندسة (خريطة الكنز والقطار المستقيم)", |
| "text": """ |
| مرحباً بك يا بطل الهندسة! اليوم سنتعلم درسين رائعين في الفضاء والرسم: |
| |
| أولاً: تحديد المواقع على المرصوفة (خريطة الكنز) (الصفحة 16) |
| المرصوفة هي شبكة مربعات تشبه خريطة الكنز السري. |
| - الأسطر مرقمة بأرقام (1، 2، 3...) والأعمدة معلمة بحروف (أ، ب، ج...). |
| - لتحديد مكان أي حيوان أو كنز، ننظر للعمود والسطر اللذين يلتقي عندهما. |
| - نكتب الموقع هكذا: (العمود ، السطر). |
| - مثلاً: إذا كان "الأسد" يقع في العمود (أ) والسطر (3)، نكتب موقعه: (أ ، 3). |
| |
| ثانياً: الاستقامية ورسم الخطوط (الصفحة 31) |
| - هل تعرف كيف تسير سكة القطار؟ تسير في خط مستقيم تماماً. |
| - لمعرفة هل النقاط مرسومة في استقامية واحدة أم لا، نضع حافة مسطرتنا عليها. |
| - إذا لامست حافة المسطرة جميع النقاط في نفس الوقت، واستطعنا رسم خط مستقيم يجمعها كلها، نقول: "هذه النقط في استقامية واحدة". |
| """, |
| "questions": [ |
| {"q": "إذا كان الغزال يقع في العمود (ج) والسطر (2)، كيف نكتب موقع خانته؟", |
| "hint": "نكتب موقعه داخل قوسين هكذا: (ج ، 2) (انظر الصفحة 16)."}, |
| {"q": "كيف نتأكد من أن النقط المرسومة على كراسك تقع في استقامية واحدة؟", |
| "hint": "نضع حافة المسطرة عليها؛ فإذا لامست حافة المسطرة كل النقط معاً، فهي في استقامية واحدة (انظر الصفحة 31)."}, |
| {"q": "إذا تحرك رائد خطوة واحدة لليمين وخطوة للأعلى من الخانة (ب ، 4)، فما هي خانته الجديدة؟", |
| "hint": "العمود ب لليمين يصبح ج، والسطر 4 للأعلى يصبح 3. الخانة الجديدة هي (ج ، 3) (انظر الصفحة 16)."} |
| ] |
| } |
| , { |
| "title": "الدرس الثالث — مقارنة أطوال (سباق النملات الذكيات)", |
| "text": """ |
| مرحباً بك يا بطل! في هذا الدرس سنتعلم كيف نقارن بين الأطوال [Page 15]. |
| |
| تخيل أن لدينا ثلاث نملات ذكيات يتسابقن للوصول إلى قطعة سكر: |
| - النملة الأولى مشت في طريق أخضر متعرج. |
| - النملة الثانية مشت في طريق بنفسجي منحنٍ. |
| - النملة الثالثة مشت في طريق برتقالي منكسر. |
| |
| كيف نعرف أي طريق هو الأقصر لتفوز النملة بالجائزة؟ |
| 1. المسطرة الصلبة لا تكفي: لأن الطرق ليست مستقيمة تماماً! |
| 2. الحل السحري: نستخدم "خيطاً" أو "شريطاً من الورق المقوى". |
| 3. طريقة القياس: |
| - نضع الخيط فوق الطريق المتعرج بدقة من البداية إلى النهاية. |
| - نقطع الخيط أو نضع علامة عليه عند نهاية الطريق. |
| - نفرد الخيط ليصبح مستقيماً، ثم نقيس طوله بالمسطرة المدرجة! |
| - نفعل نفس الشيء مع بقية الطرق، والطريق الذي يعطينا أقصر خيط هو الطريق الأقصر للفوز. |
| |
| تعلمتُ: لمقارنة أطوال صغيرة أو منحنية، نستعمل خيطاً أو شريطاً من الورق، ثم نقيسه بالمسطرة. |
| """, |
| "questions": [ |
| {"q": "إذا كان لدينا طريقان متعرجان، كيف نقارن بين طوليهما بدون مسطرة مباشرة؟", |
| "hint": "نستخدم خيطاً نضعه فوق كل طريق، ثم نقارن طولي الخيطين لنرى أيهما أطول (انظر الصفحة 15)."}, |
| {"q": "مستعيناً بالمسطرة، رتب المسارات التالية تصاعدياً (من الأقصر للأطول): 1500m، 2km 30m، 1km 25m.", |
| "hint": "نعلم أن 1km = 1000m. إذن: الأول هو 1025m، والثاني 1500m، والثالث 2030m (انظر الصفحة 57)."} |
| ] |
| }, |
| { |
| "title": "الدرس الرابع — التنقل على مرصوفة (رحلة رائد ومواقع الحيوانات)", |
| "text": """ |
| أهلاً بك يا مستكشف! اليوم سنتعلم كيف نتحرك على المرصوفة ونشفر المسارات [Page 16]. |
| |
| المرصوفة هي شبكة مربعات تشبه خريطة الألعاب: |
| - الأعمدة معلمة بحروف من الأسفل: (أ، ب، ج، د، هـ...). |
| - الأسطر مرقمة من الجانب: (1، 2، 3، 4، 5، 6...). |
| |
| أولاً: كيف نجد موقع حيوان على المرصوفة؟ |
| لتحديد مكان "الغزالة" أو "الدب"، نقوم بخطوتين: |
| 1. ننزل بأعيننا عمودياً لنرى الحرف المقابل لها (العمود). |
| 2. نذهب بأعيننا أفقياً نحو الجانب لنرى الرقم المقابل لها (السطر). |
| نكتب الموقع داخل قوسين هكذا: (الحرف ، الرقم). |
| مثال: إذا كان الدب يقع في العمود (أ) والسطر (3)، فإن تشفيره هو: (أ ، 3). |
| |
| ثانياً: كيف نشفر مسار التنقل؟ |
| عندما يتحرك رائد من المدرسة إلى المنزل، فإنه يتبع خطوات ملونة. |
| - نرمز للحركة نحو اليمين بسهم: (→) |
| - نرمز للحركة نحو الأسفل بسهم: (↓) |
| تشفير المسلك هو كتابة تتابع هذه الأسهم خطوة بخطوة حتى نصل للنهاية. |
| """, |
| "questions": [ |
| {"q": "إذا كان الفيل يقع في العمود (ج) والسطر (2)، اكتب تشفير موقع الفيل.", |
| "hint": "التشفير يكتب بين قوسين هكذا: (ج ، 2) (انظر الصفحة 16)."}, |
| {"q": "ما هي الحركات التي نرمز لها بالأسهم (→) و (↓) على المرصوفة؟", |
| "hint": "السهم (→) يعني خطوة لليمين على السطر، والسهم (↓) يعني خطوة للأسفل على العمود (انظر الصفحة 16)."} |
| ] |
| }, |
| { |
| "title": "الدرس الخامس — الأعداد من 0 إلى 999 (سباق الأرقام الكبيرة)", |
| "text": """ |
| مرحباً بك في عالم الأرقام الكبيرة! اليوم سنتعلم كيف نقرأ ونكتب الأعداد التي تتكون من 3 أرقام [Page 17]. |
| |
| عندما نرى عدداً مكتوباً على قميص عداء في السباق، مثل: (435): |
| - الرقم 5 يمثل الوحدات (و). |
| - الرقم 3 يمثل العشرات (ع). |
| - الرقم 4 يمثل المئات (م). |
| |
| أولاً: كيف نقرأ هذا العدد الكبير؟ |
| نبدأ دائماً بقراءة المئات أولاً، ثم نقفز لقراءة الوحدات، وأخيراً نقرأ العشرات: |
| - نقرأ 400: "أربعمائة". |
| - نقرأ 5: "وخمسة". |
| - نقرأ 30: "وثلاثون". |
| فيصبح العدد: "أربعمائة وخمسة وثلاثون". |
| |
| ثانياً: انتبه للأفخاخ! |
| - العدد "سبعمائة وثلاثة" يُكتب بالأرقام: (703)، وليس (730) لأن العشرات صفر، وليس (307)! |
| - العدد الذي فيه 5 وحدات و 0 عشرات و 1 مئات هو: (105). |
| |
| تعلمتُ: أكتب الأعداد بالحروف والكلمات من خلال تركيبها بانتظام ومراعاة قيمة كل رقم (وحدات، عشرات، مئات). |
| """, |
| "questions": [ |
| {"q": "اكتب العدد (ثمانمائة وستة وخمسون) بالأرقام.", |
| "hint": "نكتب 8 في المئات، و6 في الوحدات، و5 في العشرات، فيصبح: 856 (انظر الصفحة 17)."}, |
| {"q": "تخيل عدداً يملك 5 وحدات، و9 عشرات، و9 مئات. ما هو هذا العدد؟ وكيف نقرؤه؟", |
| "hint": "العدد هو 995، ونقرؤه: تسعمائة وخمسة وتسعون (انظر الصفحة 17)."} |
| ] |
| }, |
| { |
| "title": "الدرس السادس — الجمع والطرح: العد بإضافة أو طرح 1 أو 10 أو 100", |
| "text": """ |
| مرحباً بك في لعبة الحساب السريع والأنماط العددية! [Page 18]. |
| |
| سنتعلم اليوم كيف نزيد أو ننقص الأرقام بذكاء كبير وفي ثوانٍ معدودة: |
| |
| أولاً: إضافة أو طرح 1 (لعبة الوحدات) |
| عندما نزيد أو ننقص 1، يتغير فقط رقم "الوحدات" في العدد: |
| - مثال للإضافة: 500 ← 501 ← 502... (تغير الرقم الأخير فقط). |
| - مثال للطرح: 500 ← 499 ← 498... |
| |
| ثانياً: إضافة أو طرح 10 (لعبة العشرات) |
| عندما نزيد أو ننقص 10، يتغير فقط رقم "العشرات" في الوسط: |
| - مثال للطرح: 874 ← 864 ← 854... (لاحظ أن العشرات تنقص بـ 1 في كل خطوة: 7، 6، 5). |
| - مثال للإضافة: 365 + 10 = 375. |
| |
| ثالثاً: إضافة أو طرح 100 (لعبة المئات) |
| عندما نزيد أو ننقص 100، يتغير فقط الرقم الأول من اليسار وهو "المئات": |
| - مثال للإضافة: 3 ← 103 ← 203 ← 303... (المئات تزيد بـ 1 في كل مرة). |
| - مثال للطرح: 904 - 100 = 804. |
| |
| تعلمتُ: الحساب السريع والعد الشفهي بإضافة أو طرح 1 أو 10 أو 100 يسهل عليّ حل العمليات الصعبة في ذهني! |
| """, |
| "questions": [ |
| {"q": "أكمل السلسلة التالية بذكاء: 432 ← 422 ← 412 ← ... ", |
| "hint": "نلاحظ أن مرتبة العشرات تنقص بـ 10 في كل مرة، إذن العدد الموالي هو: 402 (انظر الصفحة 18)."}, |
| {"q": "احسب ذهنياً وبسرعة فائقة: 643 + 100.", |
| "hint": "نضيف 1 إلى منزلة المئات فقط (الرقم 6 يصبح 7)، والنتيجة هي: 743 (انظر الصفحة 18)."}, |
| {"q": "ما هو العدد المناسب للفراغ: ... + 100 = 320؟", |
| "hint": "نطرح 100 من الـ 320 (منزلة المئات تنقص بـ 1)، فيكون العدد هو: 220 (انظر الصفحة 18)."} |
| ] |
| } |
|
|
| ] |
|
|
|
|
| LISTEN_SECONDS = 10 |
| PASS_SCORE = 6 |
|
|
| |
| _audio_cache = {} |
| _audio_lock = threading.Lock() |
|
|
| def get_lecture_audio(lesson_idx): |
| if lesson_idx in _audio_cache and os.path.exists(_audio_cache[lesson_idx]): |
| return _audio_cache[lesson_idx] |
| with _audio_lock: |
| if lesson_idx in _audio_cache and os.path.exists(_audio_cache[lesson_idx]): |
| return _audio_cache[lesson_idx] |
| try: |
| text = LESSONS[lesson_idx]["text"] |
| tts = gTTS(text=text, lang="ar", slow=False) |
| path = tempfile.mktemp(suffix=".mp3") |
| tts.save(path) |
| _audio_cache[lesson_idx] = path |
| return path |
| except Exception as e: |
| print(f"[law-app] خطأ gTTS: {e}") |
| return None |
|
|
| |
| def fresh(): |
| return json.dumps({"phase": "idle", "listen_start": None, |
| "question_idx": None, "lesson_idx": 0}) |
|
|
| def S(s): return json.loads(s) |
| def D(d): return json.dumps(d) |
|
|
| |
| def evaluate_answer(question, answer, hint): |
| llm = get_llm() |
| if llm is None: |
| kw = [w.strip().lower() for w in hint.split("،")] |
| hits = sum(1 for k in kw if k in answer.lower()) |
| score = min(10, round((hits / max(len(kw), 1)) * 10)) |
| return score, f"(بدون ذكاء اصطناعي – الكلمات المفتاحية: {hits}/{len(kw)})" |
|
|
| prompt = ( |
| "<|im_start|>system\n" |
| "أنت أستاذ قانون جزائري صارم ومتفهم. " |
| "تقيّم إجابات الطلاب وتردّ فقط بصيغة JSON صحيحة.\n" |
| "<|im_end|>\n" |
| "<|im_start|>user\n" |
| f"السؤال: {question}\n" |
| f"إجابة الطالب: {answer}\n" |
| f"العناصر المتوقعة: {hint}\n\n" |
| "قيّم هذه الإجابة من 10 وأعطِ تعليقاً موجزاً باللغة العربية.\n" |
| "أجب فقط بهذا JSON في سطر واحد دون أي إضافة:\n" |
| '{"score": <عدد صحيح 0-10>, "feedback": "<تعليق موجز>"}\n' |
| "<|im_end|>\n" |
| "<|im_start|>assistant\n" |
| ) |
| try: |
| out = llm(prompt, max_tokens=150, temperature=0.1, |
| stop=["<|im_end|>", "\n\n", "###"]) |
| text = out["choices"][0]["text"].strip() |
| print(f"[law-app] رد النموذج: {text}") |
| start = text.find("{") |
| end = text.rfind("}") + 1 |
| if start == -1 or end == 0: |
| raise ValueError("لا يوجد JSON في الرد") |
| data = json.loads(text[start:end]) |
| return max(0, min(10, int(data["score"]))), data.get("feedback", "") |
| except Exception as e: |
| print(f"[law-app] خطأ في التقييم: {e}") |
| kw = [w.strip().lower() for w in hint.split("،")] |
| hits = sum(1 for k in kw if k in answer.lower()) |
| score = min(10, round((hits / max(len(kw), 1)) * 10)) |
| return score, f"(خطأ في النموذج – الكلمات المفتاحية: {hits}/{len(kw)})" |
|
|
| |
| def start_listening(ss): |
| state = S(ss) |
| lesson_idx = state.get("lesson_idx", 0) |
| lesson = LESSONS[lesson_idx] |
| state.update({"phase": "listen", "listen_start": time.time(), "question_idx": None}) |
| audio = get_lecture_audio(lesson_idx) |
| total = len(LESSONS) |
| msg = ( |
| f"🎧 **{lesson['title']}** ({lesson_idx + 1}/{total})\n\n" |
| "استمع لمدة **15 دقيقة** كاملة، ثم انقر على **🔄 التحقق من الوقت**." |
| ) |
| return ( |
| audio, msg, |
| gr.update(visible=True), |
| gr.update(visible=False), |
| gr.update(visible=False), |
| gr.update(visible=False), |
| gr.update(interactive=False, value="⏳ جارٍ الاستماع…"), |
| D(state), |
| ) |
|
|
| def check_timer(ss): |
| state = S(ss) |
| if state["phase"] != "listen" or not state["listen_start"]: |
| return gr.update(), "⚠️ ابدأ الاستماع أولاً.", ss |
| rem = max(0, LISTEN_SECONDS - (time.time() - state["listen_start"])) |
| m, s = divmod(int(rem), 60) |
| if rem <= 0: |
| return ( |
| gr.update(interactive=True, value="✅ الانتقال إلى الاختبار"), |
| "✅ انتهى الوقت! انقر على **الانتقال إلى الاختبار**.", |
| ss, |
| ) |
| return ( |
| gr.update(interactive=False, value=f"⏳ تبقّى {m:02d}:{s:02d}"), |
| f"🎧 انتظر **{m} دقيقة و{s:02d} ثانية** إضافية.", |
| ss, |
| ) |
|
|
| def open_quiz(ss): |
| state = S(ss) |
| elapsed = time.time() - (state["listen_start"] or 0) |
| if elapsed < LISTEN_SECONDS: |
| m, s = divmod(int(LISTEN_SECONDS - elapsed), 60) |
| return ( |
| gr.update(visible=True), gr.update(visible=False), |
| gr.update(visible=False), gr.update(visible=False), |
| f"⚠️ انتظر **{m} دقيقة و{s:02d} ثانية** إضافية.", "", "", ss, |
| ) |
| lesson_idx = state.get("lesson_idx", 0) |
| idx = random.randrange(len(LESSONS[lesson_idx]["questions"])) |
| state.update({"phase": "quiz", "question_idx": idx}) |
| return ( |
| gr.update(visible=False), gr.update(visible=True), |
| gr.update(visible=False), gr.update(visible=False), |
| "", LESSONS[lesson_idx]["questions"][idx]["q"], "", D(state), |
| ) |
|
|
| |
| def submit_answer(answer, ss): |
| state = S(ss) |
| lesson_idx = state.get("lesson_idx", 0) |
|
|
| if state["phase"] != "quiz": |
| yield ( |
| gr.update(visible=True), gr.update(visible=False), |
| gr.update(visible=False), gr.update(visible=False), |
| "", |
| gr.update(value="📤 إرسال الإجابة", interactive=True), |
| gr.update(interactive=True), |
| ss, |
| ) |
| return |
|
|
| |
| yield ( |
| gr.update(visible=False), gr.update(visible=True), |
| gr.update(visible=False), gr.update(visible=False), |
| "", |
| gr.update(value="⏳ تم إرسال الإجابة — جارٍ التصحيح…", interactive=False), |
| gr.update(interactive=False), |
| ss, |
| ) |
|
|
| |
| q = LESSONS[lesson_idx]["questions"][state["question_idx"]] |
| score, fb = evaluate_answer(q["q"], answer, q["hint"]) |
| total = len(LESSONS) |
| is_last = (lesson_idx >= total - 1) |
|
|
| if score >= PASS_SCORE: |
| state["phase"] = "pass" |
| result = ( |
| f"## 🎉 أحسنت! اجتزت الدرس {lesson_idx + 1} من {total}.\n\n" |
| f"**النتيجة: {score}/10**\n\n**التعليق:** {fb}" |
| ) |
| next_label = "🏆 عرض الشهادة" if is_last else f"➡️ {LESSONS[lesson_idx + 1]['title']}" |
| else: |
| state["phase"] = "fail" |
| result = ( |
| f"## ❌ النتيجة غير كافية: {score}/10\n\n" |
| f"**التعليق:** {fb}\n\n" |
| f"الحد الأدنى المطلوب: **{PASS_SCORE}/10**. " |
| "يُرجى **إعادة الاستماع إلى الدرس** قبل المحاولة مجدداً." |
| ) |
| next_label = "🔄 إعادة الاستماع إلى الدرس" |
|
|
| |
| yield ( |
| gr.update(visible=False), gr.update(visible=False), |
| gr.update(visible=True), gr.update(visible=False), |
| result, |
| gr.update(value="📤 إرسال الإجابة", interactive=True), |
| gr.update(value="", interactive=True), |
| D(state), |
| ) |
|
|
| def handle_next(ss): |
| state = S(ss) |
| lesson_idx = state.get("lesson_idx", 0) |
| total = len(LESSONS) |
|
|
| if state["phase"] == "fail": |
| return start_listening(D(state)) |
|
|
| if lesson_idx >= total - 1: |
| state["phase"] = "done" |
| return ( |
| None, "", |
| gr.update(visible=False), |
| gr.update(visible=False), |
| gr.update(visible=False), |
| gr.update(visible=True), |
| gr.update(interactive=False, value="✅ انتهى"), |
| D(state), |
| ) |
| else: |
| state["lesson_idx"] = lesson_idx + 1 |
| state["listen_start"] = None |
| return start_listening(D(state)) |
|
|
| def restart_from_cert(ss): |
| return start_listening(fresh()) |
|
|
| |
| CSS = """ |
| footer { display: none !important; } |
| #title { text-align: center; } |
| .gradio-container { direction: rtl; font-family: 'Amiri', 'Noto Sans Arabic', 'Segoe UI', sans-serif; } |
| .gr-button { font-family: 'Amiri', 'Noto Sans Arabic', sans-serif; } |
| """ |
|
|
| with gr.Blocks(title="دورة القانون", css=CSS) as demo: |
| session = gr.State(fresh()) |
|
|
| gr.Markdown( |
| "# ⚖️ دورة القانون — التكوين القانوني\n" |
| "استمع إلى كل درس · أجب عن السؤال · تقدّم درساً بعد درس", |
| elem_id="title", |
| ) |
|
|
| with gr.Column(visible=True) as listen_col: |
| gr.Markdown("## 🎧 الخطوة الأولى — الاستماع إلى الدرس") |
| listen_msg = gr.Markdown("انقر على **ابدأ** للبدء.") |
| audio_out = gr.Audio(label="الدرس الصوتي (باللغة العربية)", autoplay=False, interactive=False) |
| timer_info = gr.Markdown("") |
| with gr.Row(): |
| start_btn = gr.Button("▶️ بدء الاستماع", variant="primary") |
| check_btn = gr.Button("🔄 التحقق من الوقت", variant="secondary") |
| quiz_btn = gr.Button("✅ الانتقال إلى الاختبار", variant="secondary", interactive=False) |
|
|
| with gr.Column(visible=False) as quiz_col: |
| gr.Markdown("## 📝 الخطوة الثانية — سؤال التقييم") |
| question_md = gr.Markdown("") |
| answer_box = gr.Textbox(label="إجابتك", placeholder="اكتب إجابتك هنا…", lines=5) |
| submit_btn = gr.Button("📤 إرسال الإجابة", variant="primary") |
|
|
| with gr.Column(visible=False) as result_col: |
| gr.Markdown("## 📊 النتيجة") |
| result_md = gr.Markdown("") |
| next_btn = gr.Button("➡️ الدرس التالي", variant="primary") |
|
|
| with gr.Column(visible=False) as cert_col: |
| cert_md = gr.Markdown("") |
| restart_btn = gr.Button("🔄 إعادة البدء من الأول", variant="secondary") |
|
|
| |
| LISTEN_OUT = [audio_out, listen_msg, |
| listen_col, quiz_col, result_col, cert_col, |
| quiz_btn, session] |
|
|
| start_btn.click(start_listening, [session], LISTEN_OUT) |
| check_btn.click(check_timer, [session], [quiz_btn, timer_info, session]) |
|
|
| QUIZ_OUT = [listen_col, quiz_col, result_col, cert_col, |
| timer_info, question_md, answer_box, session] |
| quiz_btn.click(open_quiz, [session], QUIZ_OUT) |
|
|
| RES_OUT = [listen_col, quiz_col, result_col, cert_col, |
| result_md, submit_btn, answer_box, session] |
| submit_btn.click(submit_answer, [answer_box, session], RES_OUT) |
| answer_box.submit(submit_answer, [answer_box, session], RES_OUT) |
|
|
| next_btn.click(handle_next, [session], LISTEN_OUT) |
| restart_btn.click(restart_from_cert, [session], LISTEN_OUT) |
|
|
| |
| threading.Thread(target=get_llm, daemon=True).start() |
|
|
| if __name__ == "__main__": |
| demo.launch(server_name="0.0.0.0", server_port=7860, show_error=True) |