| """ |
| دورة القانون – 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": "الدرس الأول — مصادر القانون والتدرج الهرمي للقواعد", |
| "text": """ |
| دورة القانون المدني. مقدمة في مصادر القانون الجزائري. |
| الجزء الأول: المصادر الرسمية للقانون. |
| يقوم القانون الجزائري على عدة مصادر متدرجة. |
| في القمة توجد الدستور، الذي يُعدّ القانون الأساسي للجمهورية الجزائرية الديمقراطية الشعبية. |
| يحدد الدستور قواعد تنظيم الدولة ويضمن الحقوق الأساسية للمواطنين. |
| تحت الدستور تأتي المعاهدات الدولية والقانون الدولي. |
| بموجب أحكام الدستور، تتمتع المعاهدات المصادَق عليها بسلطة تعلو على القوانين العادية. |
| ثم تأتي القوانين التي يصادق عليها البرلمان. |
| القانون العادي يُصوَّت عليه في مجلس الشعب الوطني ومجلس الأمة. |
| القوانين العضوية تُكمّل وتوضح بعض الأحكام الدستورية وتتمتع بمكانة وسيطة. |
| اللوائح والمراسيم تشكّل المصدر الرابع الرئيسي، وتصدر عن السلطة التنفيذية. |
| الجزء الثاني: المصادر المادية للقانون. |
| الاجتهاد القضائي يلعب دوراً جوهرياً في النظام القانوني الجزائري. |
| قرارات المحكمة العليا ومجلس الدولة تفسر وتوضح نطاق النصوص القانونية. |
| الفقه القانوني يضم مجموع أعمال علماء القانون من أساتذة ومحامين وقضاة. |
| العرف مصدر قديم يحتفظ بأهمية معينة في المعاملات التجارية وبعض المسائل الأسرية. |
| الجزء الثالث: مبدأ التدرج الهرمي للقواعد. |
| نظرية هرم القواعد القانونية وضعها الفقيه النمساوي هانس كلسن في القرن العشرين. |
| كل قاعدة تستمد مشروعيتها من قاعدة أعلى منها. |
| في الجزائر، تتولى المحكمة الدستورية السهر على احترام هذا التدرج. |
| """, |
| "questions": [ |
| { |
| "q": "ما هو المصدر الذي يقع في قمة هرم القواعد القانونية في الجزائر؟", |
| "hint": "الدستور، القانون الأساسي، الجمهورية الجزائرية الديمقراطية الشعبية" |
| }, |
| { |
| "q": "ما هي نظرية هرم القواعد القانونية ومن هو مؤسسها؟", |
| "hint": "هانس كلسن، التدرج الهرمي، كل قاعدة تستمد مشروعيتها من قاعدة أعلى" |
| }, |
| { |
| "q": "ما هو دور الاجتهاد القضائي في النظام القانوني الجزائري؟", |
| "hint": "يفسر ويوضح النصوص القانونية، المحكمة العليا، مجلس الدولة" |
| }, |
| { |
| "q": "ما الفرق بين القانون العادي والقانون العضوي؟", |
| "hint": "القانون العادي يُصوَّت عليه في البرلمان، القانون العضوي يكمل الدستور، مكانة وسيطة" |
| }, |
| ], |
| }, |
| { |
| "title": "الدرس الثاني — قانون العقود", |
| "text": """ |
| دورة القانون المدني. قانون العقود. |
| الجزء الأول: تعريف شروط صحة العقد. |
| العقد هو اتفاق بين إرادتين يهدف إلى إنشاء التزامات أو تعديلها أو نقلها أو إنهائها. |
| يشترط القانون المدني الجزائري ثلاثة شروط لصحة العقد: |
| أولاً: التراضي، أي توافق إرادة الطرفين. |
| ثانياً: الأهلية، إذ يجب أن يكون كل طرف أهلاً للتعاقد. |
| ثالثاً: المحل والسبب، فيجب أن يكون موضوع العقد مشروعاً ومعيناً أو قابلاً للتعيين. |
| الرضا يجب أن يكون حراً وخالياً من العيوب. |
| يُعيب الرضا كلٌّ من: الغلط، والتدليس، والإكراه، والغبن. |
| عدم الأهلية تمسّ القاصرين وفاقدي الأهلية والمحجور عليهم. |
| الجزء الثاني: آثار العقد. |
| تنفيذ العقد يخضع لمبدأ القوة الملزمة، إذ يسري العقد بين الطرفين سريان القانون. |
| في حالة الإخلال بالعقد: يجوز للمتضرر المطالبة بالتنفيذ العيني أو فسخ العقد أو التعويض. |
| مبدأ نسبية أثر العقد يعني أن العقد لا يُنشئ التزامات إلا بين أطرافه المتعاقدة. |
| الغير لا يستطيع في الأصل الاستفادة من العقد ولا الاحتجاج به عليه. |
| الجزء الثالث: تكوين العقد. |
| يتكون العقد بالإيجاب والقبول. |
| الإيجاب يجب أن يكون محدداً وباتاً، والقبول يجب أن يكون مطابقاً للإيجاب. |
| كل رد مغاير يُعدّ إيجاباً جديداً. |
| العقود التمهيدية كالوعد بالبيع وحق الأفضلية تُنظّم مرحلة ما قبل التعاقد. |
| """, |
| "questions": [ |
| { |
| "q": "ما هي الشروط الثلاثة لصحة العقد وفق القانون المدني الجزائري؟", |
| "hint": "التراضي، الأهلية، المحل والسبب المشروع" |
| }, |
| { |
| "q": "ما هي عيوب الإرادة التي قد تؤدي إلى بطلان العقد؟", |
| "hint": "الغلط، التدليس، الإكراه، الغبن" |
| }, |
| { |
| "q": "من هم الأشخاص الذين تمسّهم حالات عدم الأهلية في القانون الجزائري؟", |
| "hint": "القاصرون، فاقدو الأهلية، المحجور عليهم" |
| }, |
| { |
| "q": "ما هو مبدأ نسبية أثر العقد؟", |
| "hint": "العقد لا يُنشئ التزامات إلا بين الأطراف المتعاقدة، الغير لا يستطيع الاستفادة منه" |
| }, |
| ], |
| }, |
| { |
| "title": "الدرس الثالث — المسؤولية المدنية", |
| "text": """ |
| دورة القانون المدني. المسؤولية المدنية. |
| الجزء الأول: التمييز بين المسؤولية المدنية والجنائية. |
| المسؤولية المدنية تهدف إلى إصلاح الضرر الذي لحق بالغير. |
| أما المسؤولية الجنائية فتهدف إلى معاقبة مرتكب الجريمة. |
| المسؤولية المدنية تسعى إلى إعادة المضرور إلى الحالة التي كان عليها قبل وقوع الضرر بمنحه التعويض. |
| الجزء الثاني: المسؤولية التقصيرية. |
| تقوم المسؤولية التقصيرية على ثلاثة أركان: الخطأ والضرر والعلاقة السببية. |
| تنص المادة 124 من القانون المدني الجزائري على أن كل فعل أيًّا كان يرتكبه الشخص بخطئه سبّب ضرراً للغير يُلزم من كان سبباً في حدوثه بالتعويض. |
| الخطأ قد يكون عمدياً أو غير عمدي. |
| الضرر يجب أن يكون محققاً ومباشراً وشخصياً. |
| على المضرور إثبات العلاقة السببية بين الخطأ والضرر. |
| الجزء الثالث: المسؤولية بدون خطأ. |
| تشمل المسؤولية بدون خطأ ثلاثة مجالات رئيسية. |
| مسؤولية حارس الشيء: يُسأل الحارس عن الأضرار التي تسببها الأشياء الموضوعة تحت حراسته. |
| مسؤولية عن فعل الغير: يكون الآباء مسؤولين عن الأضرار التي يتسبب فيها أبناؤهم القاصرون. |
| مسؤولية المنتج: يُسأل المنتج عن الضرر الناجم عن عيب في منتجه دون حاجة المضرور لإثبات الخطأ. |
| الجزء الرابع: المسؤولية العقدية. |
| حين ينشأ الضرر عن عدم تنفيذ عقد، تطبّق أحكام المسؤولية العقدية. |
| تستلزم هذه المسؤولية وجود عقد صحيح وإخلالاً بالتزاماته وضرراً ناجماً عن ذلك. |
| يستطيع المدين أن يُعفى من المسؤولية بإثبات القوة القاهرة، وهي حادث لا يمكن توقعه ولا دفعه وخارج عن إرادته. |
| """, |
| "questions": [ |
| { |
| "q": "ما الفرق بين المسؤولية المدنية والمسؤولية الجنائية؟", |
| "hint": "المدنية تُصلح الضرر وتمنح التعويض، الجنائية تعاقب مرتكب الجريمة" |
| }, |
| { |
| "q": "ما هي الأركان الثلاثة للمسؤولية التقصيرية؟", |
| "hint": "الخطأ، الضرر، العلاقة السببية، المادة 124 القانون المدني" |
| }, |
| { |
| "q": "ما هي المجالات الثلاثة للمسؤولية بدون خطأ؟", |
| "hint": "مسؤولية حارس الشيء، مسؤولية عن فعل الغير، مسؤولية المنتج" |
| }, |
| { |
| "q": "ما هي القوة القاهرة في إطار المسؤولية العقدية؟", |
| "hint": "حادث لا يمكن توقعه ولا دفعه وخارج عن إرادة المدين، يُعفيه من المسؤولية" |
| }, |
| ], |
| }, |
| ] |
|
|
| 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) |