math / app.py
ramedde's picture
Update app.py
1388a69 verified
Raw
History Blame Contribute Delete
32.4 kB
"""
دورة القانون – 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 # 900 في بيئة الإنتاج (15 دقيقة)
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
# ── الخطوة 1: تعطيل الزر فوراً وعرض رسالة الانتظار ─────────────────────
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,
)
# ── الخطوة 2: التقييم بالذكاء الاصطناعي ─────────────────────────────────
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 = "🔄 إعادة الاستماع إلى الدرس"
# ── الخطوة 3: عرض النتيجة وإعادة تفعيل الزر ─────────────────────────────
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)