Spaces:
Running
Running
| # level_test_manager.py | |
| import re | |
| import json | |
| from datetime import datetime | |
| class LevelTestManager: | |
| """ | |
| Global diagnostic level test manager. | |
| هذا الملف لا يعتمد على المنهاج الأردني في فحص المستوى. | |
| المنهاج الأردني يبقى للتدريس والشرح فقط. | |
| فحص المستوى هنا مبني على خريطة مهارات عالمية عامة: | |
| - الرياضيات: تدرج مهاري من الأعداد إلى ما قبل الجبر. | |
| - الإنجليزية: تدرج قريب من CEFR من Pre-A1 إلى B1/B1+. | |
| الواجهة المتوقعة مع بقية النظام: | |
| - create_level_test(subject, grade=None, student=None, identity=None) | |
| - format_test_for_student(test_data) | |
| - evaluate_test(test_data, answers_text) | |
| - submit_level_test(test_data, answers_text) | |
| - format_result_for_display(result) | |
| """ | |
| def __init__(self): | |
| self.version = "global-diagnostic-1.0" | |
| self.answer_letters = { | |
| "أ": 0, | |
| "ا": 0, | |
| "A": 0, | |
| "a": 0, | |
| "1": 0, | |
| "ب": 1, | |
| "B": 1, | |
| "b": 1, | |
| "2": 1, | |
| "ج": 2, | |
| "C": 2, | |
| "c": 2, | |
| "3": 2, | |
| "د": 3, | |
| "D": 3, | |
| "d": 3, | |
| "4": 3 | |
| } | |
| self.subject_aliases = { | |
| "math": "math", | |
| "mathematics": "math", | |
| "رياضيات": "math", | |
| "الرياضيات": "math", | |
| "رياضي": "math", | |
| "english": "english", | |
| "لغة انجليزية": "english", | |
| "لغة إنجليزية": "english", | |
| "انجليزي": "english", | |
| "إنجليزي": "english", | |
| "الانجليزي": "english", | |
| "الإنجليزي": "english" | |
| } | |
| self.skills_map = self.build_skills_map() | |
| self.questions_bank = self.build_questions_bank() | |
| self.plan_rules = self.build_plan_rules() | |
| def build_skills_map(self): | |
| return { | |
| "math": { | |
| "subject_title": "الرياضيات - فحص عالمي", | |
| "global_basis": "تدرج عالمي عام من المفاهيم الأساسية إلى ما قبل الجبر", | |
| "levels": [ | |
| { | |
| "level_id": "math_l1_number_sense", | |
| "title": "Number Sense / الحس العددي", | |
| "expected_grade_min": 1, | |
| "expected_grade_max": 3, | |
| "skills": [ | |
| {"id": "math_l1_counting", "title": "العد والترتيب"}, | |
| {"id": "math_l1_place_value", "title": "القيمة المنزلية"}, | |
| {"id": "math_l1_compare_numbers", "title": "مقارنة الأعداد"} | |
| ] | |
| }, | |
| { | |
| "level_id": "math_l2_operations", | |
| "title": "Operations / العمليات الأساسية", | |
| "expected_grade_min": 2, | |
| "expected_grade_max": 5, | |
| "skills": [ | |
| {"id": "math_l2_add_sub", "title": "الجمع والطرح"}, | |
| {"id": "math_l2_multiply_divide", "title": "الضرب والقسمة"}, | |
| {"id": "math_l2_word_problems", "title": "مسائل لفظية بسيطة"} | |
| ] | |
| }, | |
| { | |
| "level_id": "math_l3_fractions_decimals", | |
| "title": "Fractions & Decimals / الكسور والعشريات", | |
| "expected_grade_min": 4, | |
| "expected_grade_max": 7, | |
| "skills": [ | |
| {"id": "math_l3_fraction_concept", "title": "مفهوم الكسر"}, | |
| {"id": "math_l3_fraction_operations", "title": "عمليات على الكسور"}, | |
| {"id": "math_l3_decimals", "title": "الأعداد العشرية"} | |
| ] | |
| }, | |
| { | |
| "level_id": "math_l4_ratio_percent", | |
| "title": "Ratio & Percent / النسبة والمئوية", | |
| "expected_grade_min": 5, | |
| "expected_grade_max": 8, | |
| "skills": [ | |
| {"id": "math_l4_ratio", "title": "النسب والتناسب"}, | |
| {"id": "math_l4_percent", "title": "النسبة المئوية"}, | |
| {"id": "math_l4_rate", "title": "المعدل والسرعة"} | |
| ] | |
| }, | |
| { | |
| "level_id": "math_l5_integers_expressions", | |
| "title": "Integers & Expressions / الأعداد الصحيحة والتعابير", | |
| "expected_grade_min": 6, | |
| "expected_grade_max": 9, | |
| "skills": [ | |
| {"id": "math_l5_integers", "title": "الأعداد السالبة والموجبة"}, | |
| {"id": "math_l5_order_operations", "title": "ترتيب العمليات"}, | |
| {"id": "math_l5_expressions", "title": "التعابير الجبرية"} | |
| ] | |
| }, | |
| { | |
| "level_id": "math_l6_equations_geometry_data", | |
| "title": "Equations, Geometry & Data / المعادلات والهندسة والبيانات", | |
| "expected_grade_min": 7, | |
| "expected_grade_max": 10, | |
| "skills": [ | |
| {"id": "math_l6_linear_equations", "title": "المعادلات الخطية البسيطة"}, | |
| {"id": "math_l6_geometry", "title": "الهندسة والقياس"}, | |
| {"id": "math_l6_data", "title": "قراءة البيانات والمتوسط"} | |
| ] | |
| }, | |
| { | |
| "level_id": "math_l7_pre_algebra", | |
| "title": "Pre-Algebra / ما قبل الجبر", | |
| "expected_grade_min": 8, | |
| "expected_grade_max": 11, | |
| "skills": [ | |
| {"id": "math_l7_patterns", "title": "الأنماط والدوال البسيطة"}, | |
| {"id": "math_l7_slope", "title": "الميل والعلاقة الخطية"}, | |
| {"id": "math_l7_multi_step", "title": "مسائل متعددة الخطوات"} | |
| ] | |
| } | |
| ] | |
| }, | |
| "english": { | |
| "subject_title": "اللغة الإنجليزية - فحص عالمي", | |
| "global_basis": "تدرج قريب من CEFR من Pre-A1 إلى B1/B1+", | |
| "levels": [ | |
| { | |
| "level_id": "eng_pre_a1", | |
| "title": "Pre-A1 / قبل المبتدئ", | |
| "skills": [ | |
| {"id": "eng_pre_a1_letters", "title": "الحروف والكلمات الأساسية"}, | |
| {"id": "eng_pre_a1_basic_vocab", "title": "مفردات يومية بسيطة"} | |
| ] | |
| }, | |
| { | |
| "level_id": "eng_a1", | |
| "title": "A1 / مبتدئ", | |
| "skills": [ | |
| {"id": "eng_a1_be", "title": "فعل be والجمل البسيطة"}, | |
| {"id": "eng_a1_pronouns", "title": "الضمائر"}, | |
| {"id": "eng_a1_basic_reading", "title": "فهم جمل قصيرة"} | |
| ] | |
| }, | |
| { | |
| "level_id": "eng_a2", | |
| "title": "A2 / أساسي", | |
| "skills": [ | |
| {"id": "eng_a2_present_simple", "title": "المضارع البسيط"}, | |
| {"id": "eng_a2_past_simple", "title": "الماضي البسيط"}, | |
| {"id": "eng_a2_prepositions", "title": "حروف الجر"}, | |
| {"id": "eng_a2_reading", "title": "فهم فقرة قصيرة"} | |
| ] | |
| }, | |
| { | |
| "level_id": "eng_b1", | |
| "title": "B1 / متوسط", | |
| "skills": [ | |
| {"id": "eng_b1_tenses", "title": "الأزمنة في السياق"}, | |
| {"id": "eng_b1_modals", "title": "الأفعال المساعدة modal verbs"}, | |
| {"id": "eng_b1_inference", "title": "الاستنتاج من نص"}, | |
| {"id": "eng_b1_connectors", "title": "أدوات الربط"} | |
| ] | |
| } | |
| ] | |
| } | |
| } | |
| def build_plan_rules(self): | |
| return { | |
| "weak": { | |
| "title": "فجوة واضحة", | |
| "action": "شرح تأسيسي قصير + لعبة تقوية + 5 تدريبات + إعادة اختبار مصغر", | |
| "lesson_time_minutes": 20, | |
| "practice_questions": 5 | |
| }, | |
| "medium": { | |
| "title": "مهارة متوسطة", | |
| "action": "مراجعة مركزة + 3 تدريبات + سؤال تحدي", | |
| "lesson_time_minutes": 12, | |
| "practice_questions": 3 | |
| }, | |
| "strong": { | |
| "title": "مهارة قوية", | |
| "action": "تثبيت سريع + سؤال تحدي أو انتقال للمهارة التالية", | |
| "lesson_time_minutes": 5, | |
| "practice_questions": 1 | |
| } | |
| } | |
| def build_questions_bank(self): | |
| return { | |
| "math": self.build_math_questions(), | |
| "english": self.build_english_questions() | |
| } | |
| def q( | |
| self, | |
| qid, | |
| skill_id, | |
| level_id, | |
| difficulty, | |
| question, | |
| choices, | |
| correct_index, | |
| teacher_hint="", | |
| needs_whiteboard=False, | |
| whiteboard_prompt="", | |
| tags=None | |
| ): | |
| return { | |
| "id": qid, | |
| "skill_id": skill_id, | |
| "level_id": level_id, | |
| "difficulty": difficulty, | |
| "question": question, | |
| "choices": choices, | |
| "answer_index": correct_index, | |
| "correct_answer": choices[correct_index] if 0 <= correct_index < len(choices) else "", | |
| "teacher_hint": teacher_hint, | |
| "needs_whiteboard": needs_whiteboard, | |
| "whiteboard_prompt": whiteboard_prompt, | |
| "tags": tags or [] | |
| } | |
| def build_math_questions(self): | |
| return [ | |
| self.q( | |
| "math_001", "math_l1_counting", "math_l1_number_sense", 1, | |
| "أي عدد يأتي مباشرة بعد العدد 49؟", | |
| ["48", "50", "59", "40"], 1, | |
| "فكر في العد التصاعدي: 47، 48، 49، ماذا يأتي بعدها؟" | |
| ), | |
| self.q( | |
| "math_002", "math_l1_place_value", "math_l1_number_sense", 1, | |
| "في العدد 372، ما قيمة الرقم 7؟", | |
| ["7", "70", "700", "17"], 1, | |
| "انظر إلى منزلة الرقم 7: آحاد أم عشرات أم مئات؟" | |
| ), | |
| self.q( | |
| "math_003", "math_l1_compare_numbers", "math_l1_number_sense", 1, | |
| "أي عدد هو الأكبر؟", | |
| ["305", "350", "315", "299"], 1, | |
| "قارن منزلة المئات أولًا، ثم العشرات." | |
| ), | |
| self.q( | |
| "math_004", "math_l2_add_sub", "math_l2_operations", 2, | |
| "احسب: 468 + 257 = ؟", | |
| ["615", "725", "715", "735"], 1, | |
| "اجمع الآحاد ثم العشرات ثم المئات مع الانتباه للحمل.", | |
| True, | |
| "اكتب العددين تحت بعض، ثم اجمع من اليمين إلى اليسار." | |
| ), | |
| self.q( | |
| "math_005", "math_l2_add_sub", "math_l2_operations", 2, | |
| "احسب: 900 - 476 = ؟", | |
| ["424", "434", "524", "414"], 0, | |
| "استعمل الاستلاف خطوة خطوة.", | |
| True, | |
| "اكتب 900 فوق 476 وابدأ من الآحاد." | |
| ), | |
| self.q( | |
| "math_006", "math_l2_multiply_divide", "math_l2_operations", 2, | |
| "احسب: 12 × 8 = ؟", | |
| ["84", "88", "96", "108"], 2, | |
| "يمكنك حساب 10×8 ثم 2×8 ثم الجمع." | |
| ), | |
| self.q( | |
| "math_007", "math_l2_multiply_divide", "math_l2_operations", 2, | |
| "إذا كان 56 ÷ 7 = ؟", | |
| ["6", "7", "8", "9"], 2, | |
| "ابحث عن عدد إذا ضربته في 7 يعطي 56." | |
| ), | |
| self.q( | |
| "math_008", "math_l2_word_problems", "math_l2_operations", 3, | |
| "مع أحمد 24 قلمًا، أعطى 9 أقلام لصديقه. كم قلمًا بقي معه؟", | |
| ["13", "15", "16", "33"], 1, | |
| "الكلمة المهمة هنا: بقي. إذن العملية طرح." | |
| ), | |
| self.q( | |
| "math_009", "math_l3_fraction_concept", "math_l3_fractions_decimals", 3, | |
| "أي كسر يساوي نصف الشيء؟", | |
| ["1/3", "1/2", "2/3", "3/4"], 1, | |
| "النصف يعني جزء واحد من جزأين متساويين." | |
| ), | |
| self.q( | |
| "math_010", "math_l3_fraction_concept", "math_l3_fractions_decimals", 3, | |
| "أي كسر أكبر؟", | |
| ["1/4", "1/2", "1/8", "1/10"], 1, | |
| "عندما يكون البسط 1، المقام الأصغر يعطي كسرًا أكبر." | |
| ), | |
| self.q( | |
| "math_011", "math_l3_fraction_operations", "math_l3_fractions_decimals", 4, | |
| "احسب: 1/4 + 2/4 = ؟", | |
| ["3/4", "3/8", "2/8", "1/2"], 0, | |
| "المقامات متساوية، نجمع البسط فقط." | |
| ), | |
| self.q( | |
| "math_012", "math_l3_decimals", "math_l3_fractions_decimals", 4, | |
| "أي عدد عشري يساوي 3/10؟", | |
| ["0.03", "0.3", "3.0", "30.0"], 1, | |
| "ثلاثة أعشار تكتب 0.3." | |
| ), | |
| self.q( | |
| "math_013", "math_l3_decimals", "math_l3_fractions_decimals", 4, | |
| "احسب: 2.5 + 1.75 = ؟", | |
| ["3.25", "4.25", "4.75", "3.75"], 1, | |
| "رتب الفواصل العشرية تحت بعضها قبل الجمع.", | |
| True, | |
| "اكتب 2.50 + 1.75 ثم اجمع." | |
| ), | |
| self.q( | |
| "math_014", "math_l4_ratio", "math_l4_ratio_percent", 4, | |
| "النسبة 2 : 3 تعني أن مجموع الأجزاء هو؟", | |
| ["2", "3", "5", "6"], 2, | |
| "اجمع أجزاء النسبة: 2 + 3." | |
| ), | |
| self.q( | |
| "math_015", "math_l4_percent", "math_l4_ratio_percent", 5, | |
| "ما قيمة 25% من 80؟", | |
| ["10", "15", "20", "25"], 2, | |
| "25% تعني الربع. ربع 80 يساوي 20.", | |
| True, | |
| "اكتب 80 ÷ 4." | |
| ), | |
| self.q( | |
| "math_016", "math_l4_rate", "math_l4_ratio_percent", 5, | |
| "سيارة قطعت 120 كم في ساعتين. ما سرعتها المتوسطة؟", | |
| ["40 كم/ساعة", "60 كم/ساعة", "80 كم/ساعة", "120 كم/ساعة"], 1, | |
| "السرعة = المسافة ÷ الزمن." | |
| ), | |
| self.q( | |
| "math_017", "math_l5_integers", "math_l5_integers_expressions", 5, | |
| "احسب: -3 + 8 = ؟", | |
| ["-11", "-5", "5", "11"], 2, | |
| "ابدأ من -3 وتحرك 8 خطوات إلى اليمين على خط الأعداد." | |
| ), | |
| self.q( | |
| "math_018", "math_l5_order_operations", "math_l5_integers_expressions", 5, | |
| "احسب: 6 + 2 × 5 = ؟", | |
| ["40", "16", "30", "20"], 1, | |
| "ننفذ الضرب قبل الجمع." | |
| ), | |
| self.q( | |
| "math_019", "math_l5_expressions", "math_l5_integers_expressions", 6, | |
| "إذا كانت x = 4، فما قيمة 3x + 2؟", | |
| ["9", "12", "14", "18"], 2, | |
| "عوّض x بالعدد 4: 3×4 + 2." | |
| ), | |
| self.q( | |
| "math_020", "math_l6_linear_equations", "math_l6_equations_geometry_data", 6, | |
| "حل المعادلة: x + 7 = 15", | |
| ["7", "8", "9", "22"], 1, | |
| "اطرح 7 من الطرفين." | |
| ), | |
| self.q( | |
| "math_021", "math_l6_linear_equations", "math_l6_equations_geometry_data", 6, | |
| "حل المعادلة: 3x = 21", | |
| ["6", "7", "8", "9"], 1, | |
| "اقسم الطرفين على 3." | |
| ), | |
| self.q( | |
| "math_022", "math_l6_geometry", "math_l6_equations_geometry_data", 5, | |
| "مستطيل طوله 8 سم وعرضه 5 سم. ما مساحته؟", | |
| ["13 سم²", "26 سم²", "40 سم²", "80 سم²"], 2, | |
| "مساحة المستطيل = الطول × العرض." | |
| ), | |
| self.q( | |
| "math_023", "math_l6_geometry", "math_l6_equations_geometry_data", 6, | |
| "محيط مربع طول ضلعه 9 سم يساوي؟", | |
| ["18 سم", "27 سم", "36 سم", "81 سم"], 2, | |
| "محيط المربع = 4 × طول الضلع." | |
| ), | |
| self.q( | |
| "math_024", "math_l6_data", "math_l6_equations_geometry_data", 6, | |
| "ما متوسط الأعداد: 4، 6، 10؟", | |
| ["6", "7", "8", "20"], 1, | |
| "المتوسط = مجموع الأعداد ÷ عددها." | |
| ), | |
| self.q( | |
| "math_025", "math_l7_patterns", "math_l7_pre_algebra", 7, | |
| "ما العدد التالي في النمط: 3، 6، 12، 24، ...؟", | |
| ["30", "36", "42", "48"], 3, | |
| "كل مرة نضرب في 2." | |
| ), | |
| self.q( | |
| "math_026", "math_l7_slope", "math_l7_pre_algebra", 7, | |
| "في العلاقة y = 2x + 1، إذا كانت x = 3 فما قيمة y؟", | |
| ["5", "6", "7", "8"], 2, | |
| "عوّض x بـ 3: 2×3 + 1." | |
| ), | |
| self.q( | |
| "math_027", "math_l7_multi_step", "math_l7_pre_algebra", 8, | |
| "اشترى طالب 3 دفاتر بسعر 2 دينار للدفتر، وقلمًا بدينار واحد. كم دفع؟", | |
| ["5", "6", "7", "8"], 2, | |
| "احسب تكلفة الدفاتر أولًا، ثم أضف ثمن القلم." | |
| ), | |
| self.q( | |
| "math_028", "math_l7_multi_step", "math_l7_pre_algebra", 8, | |
| "إذا كان 2x + 5 = 17، فما قيمة x؟", | |
| ["5", "6", "7", "11"], 1, | |
| "اطرح 5 أولًا، ثم اقسم على 2.", | |
| True, | |
| "اكتب: 2x + 5 = 17، ثم 2x = 12، ثم x = 6." | |
| ) | |
| ] | |
| def build_english_questions(self): | |
| return [ | |
| self.q( | |
| "eng_001", "eng_pre_a1_letters", "eng_pre_a1", 1, | |
| "Which word starts with the letter B?", | |
| ["apple", "ball", "cat", "dog"], 1, | |
| "انظر إلى أول حرف في كل كلمة." | |
| ), | |
| self.q( | |
| "eng_002", "eng_pre_a1_basic_vocab", "eng_pre_a1", 1, | |
| "Choose the correct meaning: book", | |
| ["كتاب", "قلم", "باب", "بيت"], 0, | |
| "هذه كلمة من مفردات المدرسة الأساسية." | |
| ), | |
| self.q( | |
| "eng_003", "eng_pre_a1_basic_vocab", "eng_pre_a1", 1, | |
| "Choose the correct word for: قطة", | |
| ["dog", "cat", "car", "pen"], 1, | |
| "فكر في أسماء الحيوانات الشائعة." | |
| ), | |
| self.q( | |
| "eng_004", "eng_a1_be", "eng_a1", 2, | |
| "Choose: I ___ a student.", | |
| ["am", "is", "are", "be"], 0, | |
| "مع I نستخدم am." | |
| ), | |
| self.q( | |
| "eng_005", "eng_a1_be", "eng_a1", 2, | |
| "Choose: She ___ happy.", | |
| ["am", "is", "are", "be"], 1, | |
| "مع she نستخدم is." | |
| ), | |
| self.q( | |
| "eng_006", "eng_a1_pronouns", "eng_a1", 2, | |
| "Choose the correct pronoun: Ahmad is my friend. ___ is kind.", | |
| ["He", "She", "It", "They"], 0, | |
| "Ahmad اسم ولد، والضمير المناسب He." | |
| ), | |
| self.q( | |
| "eng_007", "eng_a1_basic_reading", "eng_a1", 2, | |
| "Read: 'Sara has a red bag.' What color is the bag?", | |
| ["blue", "red", "green", "black"], 1, | |
| "ابحث عن كلمة اللون في الجملة." | |
| ), | |
| self.q( | |
| "eng_008", "eng_a1_basic_reading", "eng_a1", 2, | |
| "Choose the correct sentence.", | |
| ["He are a teacher.", "He is a teacher.", "He am a teacher.", "He be a teacher."], 1, | |
| "مع He نستخدم is." | |
| ), | |
| self.q( | |
| "eng_009", "eng_a2_present_simple", "eng_a2", 3, | |
| "Choose: She ___ to school every day.", | |
| ["go", "goes", "going", "went"], 1, | |
| "في المضارع البسيط مع she نضيف s للفعل." | |
| ), | |
| self.q( | |
| "eng_010", "eng_a2_present_simple", "eng_a2", 3, | |
| "Choose: They ___ football on Fridays.", | |
| ["plays", "play", "playing", "played"], 1, | |
| "مع They نستخدم الفعل بدون s في المضارع البسيط." | |
| ), | |
| self.q( | |
| "eng_011", "eng_a2_past_simple", "eng_a2", 3, | |
| "Choose: Yesterday, I ___ my homework.", | |
| ["finish", "finishes", "finished", "finishing"], 2, | |
| "Yesterday تدل على الماضي." | |
| ), | |
| self.q( | |
| "eng_012", "eng_a2_past_simple", "eng_a2", 3, | |
| "Choose the past form of 'go'.", | |
| ["goed", "went", "goes", "going"], 1, | |
| "go فعل غير منتظم، الماضي منه went." | |
| ), | |
| self.q( | |
| "eng_013", "eng_a2_prepositions", "eng_a2", 3, | |
| "Choose: The cat is ___ the table.", | |
| ["under", "happy", "quickly", "blue"], 0, | |
| "نحتاج هنا حرف جر للمكان." | |
| ), | |
| self.q( | |
| "eng_014", "eng_a2_prepositions", "eng_a2", 3, | |
| "Choose: I wake up ___ 7 o'clock.", | |
| ["in", "on", "at", "under"], 2, | |
| "مع الساعة نستخدم at." | |
| ), | |
| self.q( | |
| "eng_015", "eng_a2_reading", "eng_a2", 4, | |
| "Read: 'Omar likes science because he enjoys experiments.' What does Omar enjoy?", | |
| ["stories", "experiments", "sports", "music"], 1, | |
| "ابحث عن كلمة enjoys في الجملة." | |
| ), | |
| self.q( | |
| "eng_016", "eng_a2_reading", "eng_a2", 4, | |
| "Read: 'The library closes at five.' When does the library close?", | |
| ["at four", "at five", "at six", "at seven"], 1, | |
| "ابحث عن الوقت المذكور." | |
| ), | |
| self.q( | |
| "eng_017", "eng_b1_tenses", "eng_b1", 5, | |
| "Choose: I have ___ this movie before.", | |
| ["see", "saw", "seen", "seeing"], 2, | |
| "بعد have في present perfect نستخدم التصريف الثالث." | |
| ), | |
| self.q( | |
| "eng_018", "eng_b1_tenses", "eng_b1", 5, | |
| "Choose: While I ___ TV, the phone rang.", | |
| ["watch", "watched", "was watching", "have watched"], 2, | |
| "While غالبًا تأتي مع past continuous للفعل المستمر." | |
| ), | |
| self.q( | |
| "eng_019", "eng_b1_modals", "eng_b1", 5, | |
| "Choose: You ___ wear a seat belt in a car.", | |
| ["must", "might", "could", "would"], 0, | |
| "نحتاج كلمة تدل على الإلزام." | |
| ), | |
| self.q( | |
| "eng_020", "eng_b1_connectors", "eng_b1", 5, | |
| "Choose: I was tired, ___ I finished my work.", | |
| ["because", "but", "so", "or"], 1, | |
| "يوجد تضاد بين التعب والجملة الثانية، فنستخدم but." | |
| ), | |
| self.q( | |
| "eng_021", "eng_b1_connectors", "eng_b1", 5, | |
| "Choose: I stayed home ___ it was raining.", | |
| ["because", "but", "although", "or"], 0, | |
| "الجملة الثانية تشرح السبب." | |
| ), | |
| self.q( | |
| "eng_022", "eng_b1_inference", "eng_b1", 6, | |
| "Read: 'Lina packed her swimsuit and towel. She was excited.' Where is Lina probably going?", | |
| ["to the library", "to the beach", "to the hospital", "to the bank"], 1, | |
| "استنتج من swimsuit و towel." | |
| ), | |
| self.q( | |
| "eng_023", "eng_b1_inference", "eng_b1", 6, | |
| "Read: 'The streets were wet, and people carried umbrellas.' What probably happened?", | |
| ["It rained", "It snowed", "It was very hot", "There was a test"], 0, | |
| "استنتج من wet streets و umbrellas." | |
| ), | |
| self.q( | |
| "eng_024", "eng_b1_tenses", "eng_b1", 6, | |
| "Choose the best sentence.", | |
| ["She has lived here since 2020.", "She have lived here since 2020.", "She lived here since 2020.", "She living here since 2020."], 0, | |
| "مع since وفترة مستمرة غالبًا نستخدم present perfect." | |
| ) | |
| ] | |
| def normalize_subject(self, subject): | |
| raw = str(subject or "").strip() | |
| return self.subject_aliases.get(raw, self.subject_aliases.get(raw.lower(), raw.lower())) | |
| def parse_grade_number(self, grade): | |
| if grade is None: | |
| return None | |
| text = str(grade).strip() | |
| arabic_words = { | |
| "الأول": 1, "الاول": 1, "اول": 1, | |
| "الثاني": 2, "ثاني": 2, | |
| "الثالث": 3, "ثالث": 3, | |
| "الرابع": 4, "رابع": 4, | |
| "الخامس": 5, "خامس": 5, | |
| "السادس": 6, "سادس": 6, | |
| "السابع": 7, "سابع": 7, | |
| "الثامن": 8, "ثامن": 8, | |
| "التاسع": 9, "تاسع": 9, | |
| "العاشر": 10, "عاشر": 10, | |
| "الحادي عشر": 11, "حادي عشر": 11, | |
| "الثاني عشر": 12, "ثاني عشر": 12 | |
| } | |
| for word, number in arabic_words.items(): | |
| if word in text: | |
| return number | |
| arabic_digits = "٠١٢٣٤٥٦٧٨٩" | |
| western_digits = "0123456789" | |
| trans = str.maketrans(arabic_digits, western_digits) | |
| normalized = text.translate(trans) | |
| match = re.search(r"\d+", normalized) | |
| if match: | |
| try: | |
| value = int(match.group(0)) | |
| if 1 <= value <= 12: | |
| return value | |
| except Exception: | |
| return None | |
| return None | |
| def grade_band_for_subject(self, subject_key, grade_number): | |
| if grade_number is None: | |
| return { | |
| "min_difficulty": 1, | |
| "max_difficulty": 8, | |
| "target_count": 18 if subject_key == "math" else 18 | |
| } | |
| if subject_key == "math": | |
| if grade_number <= 3: | |
| return {"min_difficulty": 1, "max_difficulty": 3, "target_count": 12} | |
| if grade_number <= 5: | |
| return {"min_difficulty": 1, "max_difficulty": 5, "target_count": 16} | |
| if grade_number <= 7: | |
| return {"min_difficulty": 2, "max_difficulty": 7, "target_count": 20} | |
| if grade_number <= 9: | |
| return {"min_difficulty": 3, "max_difficulty": 8, "target_count": 22} | |
| return {"min_difficulty": 4, "max_difficulty": 8, "target_count": 22} | |
| if subject_key == "english": | |
| if grade_number <= 3: | |
| return {"min_difficulty": 1, "max_difficulty": 3, "target_count": 12} | |
| if grade_number <= 6: | |
| return {"min_difficulty": 1, "max_difficulty": 5, "target_count": 18} | |
| if grade_number <= 9: | |
| return {"min_difficulty": 2, "max_difficulty": 6, "target_count": 20} | |
| return {"min_difficulty": 3, "max_difficulty": 6, "target_count": 20} | |
| return {"min_difficulty": 1, "max_difficulty": 8, "target_count": 18} | |
| def select_questions(self, subject_key, grade=None): | |
| questions = list(self.questions_bank.get(subject_key, [])) | |
| grade_number = self.parse_grade_number(grade) | |
| band = self.grade_band_for_subject(subject_key, grade_number) | |
| selected = [ | |
| q for q in questions | |
| if band["min_difficulty"] <= int(q.get("difficulty", 1)) <= band["max_difficulty"] | |
| ] | |
| if len(selected) < 8: | |
| selected = questions | |
| target_count = band["target_count"] | |
| if len(selected) > target_count: | |
| selected = selected[:target_count] | |
| output = [] | |
| for i, question in enumerate(selected, start=1): | |
| q2 = dict(question) | |
| q2["number"] = i | |
| output.append(q2) | |
| return output | |
| def create_level_test(self, subject="رياضيات", grade=None, student=None, identity=None, **kwargs): | |
| subject_key = self.normalize_subject(subject) | |
| if isinstance(student, dict): | |
| grade = grade or student.get("grade") or student.get("class") or student.get("level") | |
| if subject_key not in self.questions_bank: | |
| subject_key = "math" | |
| questions = self.select_questions(subject_key, grade=grade) | |
| grade_number = self.parse_grade_number(grade) | |
| test_data = { | |
| "success": True, | |
| "test_id": self.make_test_id(subject_key, identity), | |
| "version": self.version, | |
| "basis": "global_level_not_curriculum", | |
| "subject": subject, | |
| "subject_key": subject_key, | |
| "grade": grade, | |
| "grade_number": grade_number, | |
| "created_at": datetime.utcnow().isoformat(), | |
| "instructions": "هذا فحص مستوى عالمي عام لتحديد الفجوات. لا يعتمد على ترتيب المنهاج الأردني.", | |
| "questions": questions, | |
| "answer_format": "اكتب الإجابات مثل: q1: أ أو q1: A" | |
| } | |
| return test_data | |
| def make_test_id(self, subject_key, identity=None): | |
| stamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") | |
| safe_identity = str(identity or "student").replace(" ", "_")[:30] | |
| return f"global_{subject_key}_{safe_identity}_{stamp}" | |
| def create_test(self, subject="رياضيات", grade=None, student=None, identity=None, **kwargs): | |
| return self.create_level_test(subject=subject, grade=grade, student=student, identity=identity, **kwargs) | |
| def create_diagnostic_test(self, subject="رياضيات", grade=None, student=None, identity=None, **kwargs): | |
| return self.create_level_test(subject=subject, grade=grade, student=student, identity=identity, **kwargs) | |
| def build_level_test(self, subject="رياضيات", grade=None, student=None, identity=None, **kwargs): | |
| return self.create_level_test(subject=subject, grade=grade, student=student, identity=identity, **kwargs) | |
| def create_level_test_for_subject(self, subject="رياضيات", grade=None, student=None, identity=None, **kwargs): | |
| return self.create_level_test(subject=subject, grade=grade, student=student, identity=identity, **kwargs) | |
| def format_test_for_student(self, test_data): | |
| if not isinstance(test_data, dict): | |
| return "لا يوجد اختبار صالح." | |
| questions = test_data.get("questions", []) | |
| subject = test_data.get("subject", "") | |
| basis = test_data.get("basis", "") | |
| text = f""" | |
| # فحص المستوى العالمي | |
| المادة: {subject} | |
| نوع الفحص: {basis} | |
| التعليمات: | |
| اكتب الإجابات في صندوق الإجابة بهذا الشكل: | |
| q1: أ | |
| q2: ب | |
| q3: ج | |
| --- | |
| """.strip() | |
| letters = ["أ", "ب", "ج", "د"] | |
| for question in questions: | |
| number = question.get("number", "") | |
| text += f"\n\n## السؤال {number}\n" | |
| text += f"{question.get('question', '')}\n" | |
| choices = question.get("choices", []) | |
| for i, choice in enumerate(choices): | |
| letter = letters[i] if i < len(letters) else str(i + 1) | |
| text += f"\n{letter}) {choice}" | |
| if question.get("needs_whiteboard"): | |
| text += "\n\nملاحظة: هذا السؤال يستفيد من الكتابة أو الحل على ورقة." | |
| return text.strip() | |
| def parse_answers(self, answers_text): | |
| answers = {} | |
| text = str(answers_text or "").strip() | |
| if not text: | |
| return answers | |
| lines = re.split(r"[\n,؛;]+", text) | |
| for line in lines: | |
| line = line.strip() | |
| if not line: | |
| continue | |
| match = re.search(r"(?:q|س|السؤال)?\s*(\d+)\s*[:=\-\)]\s*([^\s]+)", line, flags=re.IGNORECASE) | |
| if match: | |
| number = int(match.group(1)) | |
| answer = match.group(2).strip() | |
| answers[number] = answer | |
| continue | |
| simple = re.search(r"^(\d+)\s+([^\s]+)$", line) | |
| if simple: | |
| answers[int(simple.group(1))] = simple.group(2).strip() | |
| return answers | |
| def answer_to_index(self, answer, choices=None): | |
| raw = str(answer or "").strip() | |
| raw = raw.replace("ـ", "") | |
| if raw in self.answer_letters: | |
| return self.answer_letters[raw] | |
| upper = raw.upper() | |
| if upper in self.answer_letters: | |
| return self.answer_letters[upper] | |
| if choices: | |
| for i, choice in enumerate(choices): | |
| if raw == str(choice).strip(): | |
| return i | |
| return None | |
| def evaluate_test(self, test_data, answers_text=None, answers=None, **kwargs): | |
| if not isinstance(test_data, dict): | |
| return { | |
| "success": False, | |
| "message": "بيانات الاختبار غير صالحة." | |
| } | |
| questions = test_data.get("questions", []) | |
| if not isinstance(questions, list) or not questions: | |
| return { | |
| "success": False, | |
| "message": "لا توجد أسئلة للتصحيح." | |
| } | |
| if answers is None: | |
| answers = self.parse_answers(answers_text) | |
| total = len(questions) | |
| correct = 0 | |
| details = [] | |
| skill_stats = {} | |
| level_stats = {} | |
| for question in questions: | |
| number = int(question.get("number", len(details) + 1)) | |
| choices = question.get("choices", []) | |
| correct_index = int(question.get("answer_index", 0)) | |
| student_raw = answers.get(number, "") | |
| student_index = self.answer_to_index(student_raw, choices=choices) | |
| is_correct = student_index == correct_index | |
| if is_correct: | |
| correct += 1 | |
| skill_id = question.get("skill_id", "unknown_skill") | |
| level_id = question.get("level_id", "unknown_level") | |
| if skill_id not in skill_stats: | |
| skill_stats[skill_id] = { | |
| "skill_id": skill_id, | |
| "skill_title": self.get_skill_title(test_data.get("subject_key"), skill_id), | |
| "total": 0, | |
| "correct": 0, | |
| "questions": [] | |
| } | |
| skill_stats[skill_id]["total"] += 1 | |
| if is_correct: | |
| skill_stats[skill_id]["correct"] += 1 | |
| skill_stats[skill_id]["questions"].append(number) | |
| if level_id not in level_stats: | |
| level_stats[level_id] = { | |
| "level_id": level_id, | |
| "level_title": self.get_level_title(test_data.get("subject_key"), level_id), | |
| "total": 0, | |
| "correct": 0 | |
| } | |
| level_stats[level_id]["total"] += 1 | |
| if is_correct: | |
| level_stats[level_id]["correct"] += 1 | |
| details.append({ | |
| "number": number, | |
| "question_id": question.get("id"), | |
| "skill_id": skill_id, | |
| "skill_title": self.get_skill_title(test_data.get("subject_key"), skill_id), | |
| "student_answer": student_raw, | |
| "student_index": student_index, | |
| "correct_index": correct_index, | |
| "correct_answer": question.get("correct_answer", ""), | |
| "is_correct": is_correct, | |
| "teacher_hint": question.get("teacher_hint", "") | |
| }) | |
| percent = round((correct / total) * 100, 2) if total else 0 | |
| skill_analysis = self.analyze_skill_stats(skill_stats) | |
| level_analysis = self.analyze_level_stats(level_stats) | |
| subject_key = test_data.get("subject_key") | |
| global_level = self.estimate_global_level(subject_key, percent, level_analysis) | |
| gap_plan = self.build_gap_plan(subject_key, skill_analysis, global_level) | |
| return { | |
| "success": True, | |
| "test_id": test_data.get("test_id"), | |
| "version": self.version, | |
| "basis": "global_level_not_curriculum", | |
| "subject": test_data.get("subject"), | |
| "subject_key": subject_key, | |
| "grade": test_data.get("grade"), | |
| "total_questions": total, | |
| "correct_answers": correct, | |
| "percent": percent, | |
| "global_level": global_level, | |
| "skill_analysis": skill_analysis, | |
| "level_analysis": level_analysis, | |
| "weak_skills": [s for s in skill_analysis if s.get("status") == "weak"], | |
| "medium_skills": [s for s in skill_analysis if s.get("status") == "medium"], | |
| "strong_skills": [s for s in skill_analysis if s.get("status") == "strong"], | |
| "gap_plan": gap_plan, | |
| "details": details, | |
| "created_at": datetime.utcnow().isoformat(), | |
| "next_step": gap_plan.get("next_step", "ابدأ بخطة تقوية قصيرة حسب أضعف المهارات.") | |
| } | |
| def submit_level_test(self, test_data, answers_text=None, **kwargs): | |
| return self.evaluate_test(test_data, answers_text=answers_text, **kwargs) | |
| def grade_test(self, test_data, answers_text=None, **kwargs): | |
| return self.evaluate_test(test_data, answers_text=answers_text, **kwargs) | |
| def evaluate_answers(self, test_data, answers_text=None, **kwargs): | |
| return self.evaluate_test(test_data, answers_text=answers_text, **kwargs) | |
| def analyze_skill_stats(self, skill_stats): | |
| output = [] | |
| for skill_id, item in skill_stats.items(): | |
| total = item.get("total", 0) | |
| correct = item.get("correct", 0) | |
| percent = round((correct / total) * 100, 2) if total else 0 | |
| if percent < 50: | |
| status = "weak" | |
| elif percent < 80: | |
| status = "medium" | |
| else: | |
| status = "strong" | |
| output.append({ | |
| "skill_id": skill_id, | |
| "skill_title": item.get("skill_title", skill_id), | |
| "total": total, | |
| "correct": correct, | |
| "percent": percent, | |
| "status": status, | |
| "questions": item.get("questions", []) | |
| }) | |
| status_order = {"weak": 0, "medium": 1, "strong": 2} | |
| output.sort(key=lambda x: (status_order.get(x.get("status"), 9), x.get("percent", 0))) | |
| return output | |
| def analyze_level_stats(self, level_stats): | |
| output = [] | |
| for level_id, item in level_stats.items(): | |
| total = item.get("total", 0) | |
| correct = item.get("correct", 0) | |
| percent = round((correct / total) * 100, 2) if total else 0 | |
| output.append({ | |
| "level_id": level_id, | |
| "level_title": item.get("level_title", level_id), | |
| "total": total, | |
| "correct": correct, | |
| "percent": percent | |
| }) | |
| return output | |
| def estimate_global_level(self, subject_key, percent, level_analysis): | |
| if subject_key == "english": | |
| if percent < 25: | |
| return { | |
| "code": "pre_a1_gap", | |
| "title": "Pre-A1 مع فجوات واضحة", | |
| "description": "الطالب يحتاج تأسيسًا في الكلمات والجمل الأساسية." | |
| } | |
| if percent < 45: | |
| return { | |
| "code": "a1", | |
| "title": "A1 مبتدئ", | |
| "description": "الطالب يفهم بعض الجمل الأساسية ويحتاج بناء القواعد والمفردات." | |
| } | |
| if percent < 65: | |
| return { | |
| "code": "a2", | |
| "title": "A2 أساسي", | |
| "description": "الطالب قادر على فهم جمل وفقرات قصيرة ويحتاج تطوير القراءة والقواعد." | |
| } | |
| if percent < 82: | |
| return { | |
| "code": "b1", | |
| "title": "B1 متوسط", | |
| "description": "الطالب جيد في الأساسيات ويحتاج تقوية الاستنتاج والأزمنة في السياق." | |
| } | |
| return { | |
| "code": "b1_plus", | |
| "title": "B1+ / بداية B2", | |
| "description": "الطالب متقدم نسبيًا ويحتاج تحديات قراءة وكتابة أعمق." | |
| } | |
| if percent < 35: | |
| return { | |
| "code": "foundation_gap", | |
| "title": "فجوة تأسيسية واضحة", | |
| "description": "يحتاج الطالب إلى إعادة بناء المهارات الأساسية قبل التقدم." | |
| } | |
| if percent < 55: | |
| return { | |
| "code": "basic", | |
| "title": "مستوى أساسي", | |
| "description": "يمتلك الطالب بعض الأساسيات مع فجوات تحتاج خطة قصيرة." | |
| } | |
| if percent < 75: | |
| return { | |
| "code": "developing", | |
| "title": "مستوى نامٍ", | |
| "description": "الطالب قريب من المستوى المناسب ويحتاج تثبيتًا وممارسة." | |
| } | |
| if percent < 90: | |
| return { | |
| "code": "on_track", | |
| "title": "على المسار", | |
| "description": "الطالب جيد ويمكنه متابعة الدروس مع معالجة فجوات محددة." | |
| } | |
| return { | |
| "code": "advanced", | |
| "title": "متقدم", | |
| "description": "الطالب قوي ويحتاج أسئلة تحدي وتوسيع." | |
| } | |
| def build_gap_plan(self, subject_key, skill_analysis, global_level): | |
| weak = [s for s in skill_analysis if s.get("status") == "weak"] | |
| medium = [s for s in skill_analysis if s.get("status") == "medium"] | |
| strong = [s for s in skill_analysis if s.get("status") == "strong"] | |
| priority_skills = weak[:5] if weak else medium[:3] | |
| steps = [] | |
| for skill in priority_skills: | |
| status = skill.get("status") | |
| rule = self.plan_rules.get(status, self.plan_rules["medium"]) | |
| steps.append({ | |
| "skill_id": skill.get("skill_id"), | |
| "skill_title": skill.get("skill_title"), | |
| "status": status, | |
| "action": rule.get("action"), | |
| "lesson_time_minutes": rule.get("lesson_time_minutes"), | |
| "practice_questions": rule.get("practice_questions"), | |
| "suggested_games": self.suggest_games_for_skill(subject_key, skill.get("skill_id")) | |
| }) | |
| if weak: | |
| next_step = "ابدأ بخطة قصر الفجوة من أضعف مهارة، ولا تدخل درسًا جديدًا قبل نشاط تقوية قصير." | |
| elif medium: | |
| next_step = "ابدأ الدرس مع مراجعة مركزة للمهارات المتوسطة." | |
| else: | |
| next_step = "الطالب جاهز للدرس، استخدم أسئلة تحدي وألعاب مراجعة قصيرة." | |
| return { | |
| "global_level": global_level, | |
| "priority_skills": priority_skills, | |
| "steps": steps, | |
| "summary": { | |
| "weak_count": len(weak), | |
| "medium_count": len(medium), | |
| "strong_count": len(strong) | |
| }, | |
| "next_step": next_step | |
| } | |
| def suggest_games_for_skill(self, subject_key, skill_id): | |
| skill_id = str(skill_id or "") | |
| if subject_key == "english": | |
| if "reading" in skill_id or "inference" in skill_id: | |
| return ["treasure_hunt", "mailman"] | |
| if "vocab" in skill_id or "letters" in skill_id: | |
| return ["mickey_mouse", "treasure_hunt"] | |
| return ["beauty_beast", "obstacle_race"] | |
| if "fraction" in skill_id or "decimal" in skill_id: | |
| return ["obstacle_race", "magic_cube"] | |
| if "operations" in skill_id or "add" in skill_id or "multiply" in skill_id: | |
| return ["fishing_friends", "sonic"] | |
| if "ratio" in skill_id or "percent" in skill_id: | |
| return ["light_bulbs", "obstacle_race"] | |
| if "geometry" in skill_id or "data" in skill_id: | |
| return ["science_world", "magic_cube"] | |
| if "patterns" in skill_id or "integers" in skill_id: | |
| return ["treasure_hunt", "obstacle_race"] | |
| return ["fishing_friends", "treasure_hunt"] | |
| def get_skill_title(self, subject_key, skill_id): | |
| subject_key = self.normalize_subject(subject_key) | |
| subject = self.skills_map.get(subject_key, {}) | |
| for level in subject.get("levels", []): | |
| for skill in level.get("skills", []): | |
| if skill.get("id") == skill_id: | |
| return skill.get("title", skill_id) | |
| return skill_id | |
| def get_level_title(self, subject_key, level_id): | |
| subject_key = self.normalize_subject(subject_key) | |
| subject = self.skills_map.get(subject_key, {}) | |
| for level in subject.get("levels", []): | |
| if level.get("level_id") == level_id: | |
| return level.get("title", level_id) | |
| return level_id | |
| def format_result_for_display(self, result): | |
| if not isinstance(result, dict): | |
| return "لا توجد نتيجة صالحة." | |
| if not result.get("success", True): | |
| return f"❌ {result.get('message', 'فشل التصحيح.')}" | |
| global_level = result.get("global_level", {}) | |
| gap_plan = result.get("gap_plan", {}) | |
| weak_skills = result.get("weak_skills", []) | |
| medium_skills = result.get("medium_skills", []) | |
| strong_skills = result.get("strong_skills", []) | |
| text = f""" | |
| # نتيجة فحص المستوى العالمي | |
| المادة: {result.get('subject', '')} | |
| النتيجة: {result.get('correct_answers', 0)} من {result.get('total_questions', 0)} | |
| النسبة: {result.get('percent', 0)}% | |
| ## المستوى العالمي التقديري | |
| {global_level.get('title', '')} | |
| {global_level.get('description', '')} | |
| --- | |
| ## المهارات الضعيفة | |
| """.strip() | |
| if weak_skills: | |
| for skill in weak_skills: | |
| text += f"\n- {skill.get('skill_title')}: {skill.get('correct')}/{skill.get('total')} ({skill.get('percent')}%)" | |
| else: | |
| text += "\nلا توجد مهارات ضعيفة واضحة في هذا الفحص." | |
| text += "\n\n## المهارات المتوسطة\n" | |
| if medium_skills: | |
| for skill in medium_skills: | |
| text += f"\n- {skill.get('skill_title')}: {skill.get('correct')}/{skill.get('total')} ({skill.get('percent')}%)" | |
| else: | |
| text += "\nلا توجد مهارات متوسطة تحتاج متابعة واضحة." | |
| text += "\n\n## المهارات القوية\n" | |
| if strong_skills: | |
| for skill in strong_skills[:8]: | |
| text += f"\n- {skill.get('skill_title')}: {skill.get('correct')}/{skill.get('total')} ({skill.get('percent')}%)" | |
| else: | |
| text += "\nلم تظهر مهارات قوية كافية بعد." | |
| text += "\n\n---\n\n## خطة قصر الفجوة المقترحة\n" | |
| steps = gap_plan.get("steps", []) | |
| if steps: | |
| for i, step in enumerate(steps, start=1): | |
| games = ", ".join(step.get("suggested_games", [])) | |
| text += f"\n### خطوة {i}: {step.get('skill_title')}\n" | |
| text += f"- الحالة: {step.get('status')}\n" | |
| text += f"- الإجراء: {step.get('action')}\n" | |
| text += f"- الزمن المقترح: {step.get('lesson_time_minutes')} دقيقة\n" | |
| text += f"- تدريبات مقترحة: {step.get('practice_questions')}\n" | |
| text += f"- ألعاب مناسبة: {games}\n" | |
| else: | |
| text += "\nلا توجد فجوات واضحة. استخدم نشاط تحدي قصير." | |
| text += f"\n\n## الخطوة التالية\n{result.get('next_step', '')}" | |
| return text.strip() | |
| def export_skills_map_json(self): | |
| return json.dumps(self.skills_map, ensure_ascii=False, indent=2) | |
| def export_questions_json(self): | |
| return json.dumps(self.questions_bank, ensure_ascii=False, indent=2) |