Asem75 commited on
Commit
3ca2e98
·
verified ·
1 Parent(s): 1e298dc

Upload 11 files

Browse files
app.py CHANGED
@@ -35,12 +35,12 @@ import re
35
  import sys
36
  from pathlib import Path
37
 
38
- BRIDGE_VERSION = "app_stage_student_full_whiteboards_kindergarten_v4_0"
39
- BUILD_ID = "STUDENT_FULL_K12_WHITEBOARD_001"
40
  ROOT = Path(__file__).resolve().parent
41
  ORIGINAL_APP = ROOT / "app_original.py"
42
  PATCHED_CACHE = Path(
43
- os.getenv("DIVID_PATCHED_APP_PATH", "/tmp/divid_teacher_app_stage_student_full_whiteboards_kindergarten_v4_0.py")
44
  )
45
 
46
 
@@ -99,6 +99,7 @@ set_math_whiteboard_task_from_dict = None
99
  math_whiteboard_grade_profile_text = None
100
  math_whiteboard_to_png_file = None
101
  math_whiteboard_to_json_file = None
 
102
 
103
  try:
104
  from math_whiteboard_manager import (
@@ -108,6 +109,7 @@ try:
108
  grade_profile_text as math_whiteboard_grade_profile_text,
109
  whiteboard_to_png_file as math_whiteboard_to_png_file,
110
  whiteboard_to_json_file as math_whiteboard_to_json_file,
 
111
  )
112
  MATH_WHITEBOARD_AVAILABLE = True
113
  except Exception as e:
@@ -209,6 +211,14 @@ def get_admin_manager():
209
  if not isinstance(question, dict):
210
  return []
211
 
 
 
 
 
 
 
 
 
212
  buttons = question.get("option_buttons") or []
213
  if isinstance(buttons, list) and buttons:
214
  output = []
@@ -398,49 +408,101 @@ def _badge_math_board_from_state(app_state, current_board_value=None):
398
  profile = (
399
  str(math_whiteboard_grade_profile_text(grade))
400
  if math_whiteboard_grade_profile_text is not None
401
- else "سبورة أنشطة الرياضيات"
402
  )
403
  except Exception:
404
- profile = "سبورة أنشطة الرياضيات"
405
-
406
- skill_id = _badge_math_skill_from_state(app_state)
407
- if not skill_id or set_math_whiteboard_task_from_dict is None:
408
- if set_math_whiteboard_grade is not None:
409
- try:
410
- return (
411
- set_math_whiteboard_grade(grade, current_board_value),
412
- profile,
413
- "ابدأ الوسام لتحميل نشاط مرتبط بالمهارة.",
414
- )
415
- except Exception:
416
- pass
417
- return current_board_value or gr.skip(), profile, "لم يتم تحميل نشاط بعد."
418
 
419
  try:
420
  from question_bank_manager import get_manager as get_question_bank_manager
421
 
422
- result = get_question_bank_manager().get_whiteboard_task_for_skill(
423
- skill_id=skill_id,
424
- grade=grade,
425
- source="badge",
426
- include_answer=True,
427
- seed=f"{app_state.get('identity','student')}|{skill_id}",
428
- )
429
- task = result.get("task") if isinstance(result, dict) else None
430
- if isinstance(task, dict) and task.get("task_id"):
431
  value = set_math_whiteboard_task_from_dict(task, current_board_value)
432
- feature_title = str(
433
- (result.get("whiteboard_feature") or {}).get("title_ar")
434
- or task.get("math_feature_id")
435
- or "نشاط رياضي"
 
 
 
 
 
 
 
 
 
 
 
 
436
  )
437
- return value, profile, f"تم تحميل: {feature_title}"
 
 
 
 
 
 
 
 
438
  except Exception as e:
439
- return current_board_value or gr.skip(), profile, f"تعذر تحميل نشاط المهارة: {e}"
440
 
441
- return current_board_value or gr.skip(), profile, "لا يوجد نشاط منفذ لهذه المهارة بعد."
442
 
443
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
444
  def ui_load_next_student_math_activity(app_state, current_board_value=None):
445
  app_state = dict(app_state or {})
446
  grade = safe_int(app_state.get("grade", DEFAULT_GRADE), 1)
@@ -2547,7 +2609,8 @@ def ui_check():'''
2547
  lines=8,
2548
  )
2549
  with gr.Row():
2550
- load_student_math_activity_btn = gr.Button("تحميل نشاط رياضي جديد", variant="primary")
 
2551
  badge_apply_board_grade = gr.Button("تحديث أدوات النشاط حسب الصف")
2552
  badge_save_board_png = gr.Button("حفظ صورة النشاط PNG")
2553
  badge_save_board_json = gr.Button("حفظ ملف النشاط JSON")
@@ -2776,6 +2839,12 @@ def ui_check():'''
2776
  [parent_message_thread],
2777
  )
2778
 
 
 
 
 
 
 
2779
  load_student_math_activity_btn.click(
2780
  ui_load_next_student_math_activity,
2781
  [app_state, badge_whiteboard],
@@ -2978,7 +3047,7 @@ def ui_check():'''
2978
  lines.append("- admin_tools_hidden_until_login: `True`")
2979
  lines.append("- correspondence_inside_authenticated_portals: `True`")
2980
  lines.append("- remedial_video_renderer: `direct_html_gradio_file_url`")
2981
- lines.append("- badge_game_ui: `millionaire_four_buttons_plus_unknown`")
2982
  lines.append("- whiteboard_integration: `student_free_board_plus_math_activity_board`")
2983
  lines.append("- correspondence_ready: `True`")
2984
  lines.append("- correspondence_lines: `outgoing + incoming`")
@@ -3206,7 +3275,7 @@ pre {
3206
  'gradio_allowed_path("generated_media")',
3207
  '"video_direct_url"',
3208
  '[admin_out, admin_media_html]',
3209
- 'badge_game_ui: `millionaire_four_buttons_plus_unknown`',
3210
  'whiteboard_integration: `student_free_board_plus_math_activity_board`',
3211
  'with gr.Tab("السبورة", visible=False) as whiteboard_tab:',
3212
  ]
 
35
  import sys
36
  from pathlib import Path
37
 
38
+ BRIDGE_VERSION = "app_stage_complete_question_bank_v5_3"
39
+ BUILD_ID = "COMPLETE_QUESTION_BANK_K12_001"
40
  ROOT = Path(__file__).resolve().parent
41
  ORIGINAL_APP = ROOT / "app_original.py"
42
  PATCHED_CACHE = Path(
43
+ os.getenv("DIVID_PATCHED_APP_PATH", "/tmp/divid_teacher_app_stage_complete_question_bank_v5_3.py")
44
  )
45
 
46
 
 
99
  math_whiteboard_grade_profile_text = None
100
  math_whiteboard_to_png_file = None
101
  math_whiteboard_to_json_file = None
102
+ get_math_whiteboard_response = None
103
 
104
  try:
105
  from math_whiteboard_manager import (
 
109
  grade_profile_text as math_whiteboard_grade_profile_text,
110
  whiteboard_to_png_file as math_whiteboard_to_png_file,
111
  whiteboard_to_json_file as math_whiteboard_to_json_file,
112
+ get_whiteboard_response as get_math_whiteboard_response,
113
  )
114
  MATH_WHITEBOARD_AVAILABLE = True
115
  except Exception as e:
 
211
  if not isinstance(question, dict):
212
  return []
213
 
214
+ item_type = str(
215
+ question.get("item_type")
216
+ or question.get("question_type")
217
+ or "multiple_choice"
218
+ ).strip().lower()
219
+ if item_type not in {"multiple_choice", "true_false", "operation"}:
220
+ return []
221
+
222
  buttons = question.get("option_buttons") or []
223
  if isinstance(buttons, list) and buttons:
224
  output = []
 
408
  profile = (
409
  str(math_whiteboard_grade_profile_text(grade))
410
  if math_whiteboard_grade_profile_text is not None
411
+ else "أداة السؤال التفاعلية"
412
  )
413
  except Exception:
414
+ profile = "أداة السؤال التفاعلية"
415
+
416
+ state = app_state.get("badge_state") or {}
417
+ question = state.get("current_question") or state.get("question") or {}
418
+ if not isinstance(question, dict) or not question:
419
+ questions = state.get("questions") or []
420
+ index = safe_int(state.get("current_index", 0), 0)
421
+ if isinstance(questions, list) and 0 <= index < len(questions):
422
+ question = questions[index] if isinstance(questions[index], dict) else {}
 
 
 
 
 
423
 
424
  try:
425
  from question_bank_manager import get_manager as get_question_bank_manager
426
 
427
+ bank = get_question_bank_manager()
428
+ if isinstance(question, dict) and question.get("question_id"):
429
+ task = bank.question_to_whiteboard_task(
430
+ question,
431
+ source="badge_question",
432
+ include_answer=True,
433
+ max_attempts=1,
434
+ stop_on_first_confirmed_error=True,
435
+ )
436
  value = set_math_whiteboard_task_from_dict(task, current_board_value)
437
+ item_type = str(question.get("item_type") or "multiple_choice")
438
+ return (
439
+ value,
440
+ profile,
441
+ f"تم تحميل سؤال فعلي من النوع: {item_type}. "
442
+ "أجب داخل الأداة واضغط تأكيد الإجابة، ثم اعتماد إجابة النشاط.",
443
+ )
444
+
445
+ skill_id = _badge_math_skill_from_state(app_state)
446
+ if skill_id:
447
+ result = bank.get_whiteboard_task_for_skill(
448
+ skill_id=skill_id,
449
+ grade=grade,
450
+ source="badge_practice",
451
+ include_answer=True,
452
+ seed=f"{app_state.get('identity','student')}|{skill_id}",
453
  )
454
+ task = result.get("task") if isinstance(result, dict) else None
455
+ if isinstance(task, dict) and task.get("task_id"):
456
+ value = set_math_whiteboard_task_from_dict(task, current_board_value)
457
+ feature_title = str(
458
+ (result.get("whiteboard_feature") or {}).get("title_ar")
459
+ or task.get("math_feature_id")
460
+ or "نشاط رياضي"
461
+ )
462
+ return value, profile, f"تم تحميل نشاط تدريب: {feature_title}"
463
  except Exception as e:
464
+ return current_board_value or gr.skip(), profile, f"تعذر تحميل أداة السؤال: {e}"
465
 
466
+ return current_board_value or gr.skip(), profile, "ابدأ الوسام لتحميل السؤال الفعلي."
467
 
468
 
469
+ def ui_answer_badge_from_board(app_state, board_value):
470
+ if get_math_whiteboard_response is None:
471
+ state = app_state or {}
472
+ updates = _millionaire_button_updates(state)
473
+ board, profile, status = _badge_math_board_from_state(state, board_value)
474
+ return (
475
+ state,
476
+ "تعذر قراءة إجابة أداة السؤال.",
477
+ *updates,
478
+ "أداة السؤال غير متاحة.",
479
+ "",
480
+ "",
481
+ "",
482
+ board,
483
+ profile,
484
+ status,
485
+ )
486
+
487
+ response = get_math_whiteboard_response(board_value)
488
+ if not isinstance(response, dict) or not response:
489
+ state = app_state or {}
490
+ updates = _millionaire_button_updates(state)
491
+ board, profile, status = _badge_math_board_from_state(state, board_value)
492
+ return (
493
+ state,
494
+ "أجب داخل الأداة واضغط «تأكيد الإجابة» أولًا.",
495
+ *updates,
496
+ "لم يتم العثور على إجابة مؤكدة.",
497
+ "",
498
+ "",
499
+ "",
500
+ board,
501
+ profile,
502
+ status,
503
+ )
504
+ return ui_answer_badge_millionaire(app_state, response)
505
+
506
  def ui_load_next_student_math_activity(app_state, current_board_value=None):
507
  app_state = dict(app_state or {})
508
  grade = safe_int(app_state.get("grade", DEFAULT_GRADE), 1)
 
2609
  lines=8,
2610
  )
2611
  with gr.Row():
2612
+ submit_badge_board_answer_btn = gr.Button("اعتماد إجابة النشاط", variant="primary")
2613
+ load_student_math_activity_btn = gr.Button("تحميل نشاط تدريب إضافي")
2614
  badge_apply_board_grade = gr.Button("تحديث أدوات النشاط حسب الصف")
2615
  badge_save_board_png = gr.Button("حفظ صورة النشاط PNG")
2616
  badge_save_board_json = gr.Button("حفظ ملف النشاط JSON")
 
2839
  [parent_message_thread],
2840
  )
2841
 
2842
+ submit_badge_board_answer_btn.click(
2843
+ ui_answer_badge_from_board,
2844
+ [app_state, badge_whiteboard],
2845
+ [app_state, badge_text, badge_answer_a, badge_answer_b, badge_answer_c, badge_answer_d, badge_unknown, badge_debug, lesson_text, remedial_video, remedial_task_id, badge_whiteboard, badge_wb_profile, badge_board_status],
2846
+ )
2847
+
2848
  load_student_math_activity_btn.click(
2849
  ui_load_next_student_math_activity,
2850
  [app_state, badge_whiteboard],
 
3047
  lines.append("- admin_tools_hidden_until_login: `True`")
3048
  lines.append("- correspondence_inside_authenticated_portals: `True`")
3049
  lines.append("- remedial_video_renderer: `direct_html_gradio_file_url`")
3050
+ lines.append("- badge_game_ui: `dynamic_multi_type_plus_whiteboard_and_unknown`")
3051
  lines.append("- whiteboard_integration: `student_free_board_plus_math_activity_board`")
3052
  lines.append("- correspondence_ready: `True`")
3053
  lines.append("- correspondence_lines: `outgoing + incoming`")
 
3275
  'gradio_allowed_path("generated_media")',
3276
  '"video_direct_url"',
3277
  '[admin_out, admin_media_html]',
3278
+ 'badge_game_ui: `dynamic_multi_type_plus_whiteboard_and_unknown`',
3279
  'whiteboard_integration: `student_free_board_plus_math_activity_board`',
3280
  'with gr.Tab("السبورة", visible=False) as whiteboard_tab:',
3281
  ]
badge_game_manager.py CHANGED
@@ -725,13 +725,12 @@ class BadgeGameManager:
725
  ) -> Dict[str, Any]:
726
  q = dict(question) if isinstance(question, dict) else {}
727
  badge_id = self.normalize_badge_id(q.get("badge_id") or badge_id)
728
-
729
  prompt = str(
730
- q.get("prompt")
731
- or q.get("question")
732
- or q.get("text")
733
- or "اختر الإجابة الصحيحة."
734
  ).strip()
 
 
 
735
 
736
  raw_options = q.get("options") or q.get("choices") or []
737
  if isinstance(raw_options, dict):
@@ -739,34 +738,48 @@ class BadgeGameManager:
739
  if not isinstance(raw_options, list):
740
  raw_options = [raw_options]
741
 
742
- answer = str(q.get("answer") or q.get("correct_answer") or "").strip()
743
- if not answer and raw_options:
744
- answer = str(raw_options[0])
745
-
746
- answer_text = self._strip_choice_prefix(answer)
747
- option_texts = self._ensure_four_options(prompt, raw_options, answer_text)
748
-
749
- if shuffle_options:
750
- random.shuffle(option_texts)
751
-
752
- labeled_options, option_buttons = self._label_options(option_texts)
 
 
 
 
 
 
 
 
 
 
 
 
753
 
754
  cleaned = dict(q)
755
  cleaned.update(
756
  {
757
  "question_id": str(
758
- q.get("question_id")
759
- or f"{badge_id}_{uuid.uuid4().hex[:10]}"
760
  ),
761
  "badge_id": badge_id,
762
  "prompt": prompt,
763
  "question": prompt,
764
  "text": prompt,
765
- "answer": answer_text,
766
- "correct_answer": answer_text,
 
 
 
767
  "option_texts": option_texts,
768
- "options": labeled_options,
769
- "choices": labeled_options,
770
  "option_buttons": option_buttons,
771
  "unknown_option": {
772
  "id": "unknown",
@@ -776,11 +789,10 @@ class BadgeGameManager:
776
  "separate": True,
777
  },
778
  "unknown_label": self.UNKNOWN_LABEL,
779
- "game_style": "millionaire",
780
  "answer_locked": False,
781
  }
782
  )
783
-
784
  cleaned.setdefault("subject", "math")
785
  cleaned.setdefault("subject_name", "رياضيات")
786
  cleaned.setdefault("skill_id", badge_id)
@@ -789,22 +801,35 @@ class BadgeGameManager:
789
  cleaned.setdefault("difficulty", 1)
790
  cleaned.setdefault("error_type_if_wrong", "unknown_error")
791
  cleaned.setdefault("explanation", "")
792
-
 
793
  return cleaned
794
 
795
  def question_text(self, question: Dict[str, Any], index: int = 0, total: int = 0) -> str:
796
  prompt = str(
797
- question.get("prompt")
798
- or question.get("question")
799
- or question.get("text")
800
- or ""
801
  ).strip()
802
-
803
- # لا نكرر الخيارات داخل النص؛ app.py يعرضها كأزرار مستقلة.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
804
  return (
805
  f"السؤال {index + 1} من {total}\n\n"
806
  f"{prompt}\n\n"
807
- "اختر إجابتك من الأزرار، أو اضغط لا أعلم."
808
  ).strip()
809
 
810
  def build_question_ui(
@@ -918,7 +943,7 @@ class BadgeGameManager:
918
  "skill_title": cleaned_questions[0].get("skill_title", (badge or {}).get("skill_title", "")),
919
  "mastery_scope": "exact_skill" if (exact_skill_id or str(badge_id).startswith("DIVID-MATH-")) else "legacy_badge",
920
  "status": "active",
921
- "game_style": "millionaire",
922
  "assessment_mode": "exact_skill_mastery_check",
923
  "millionaire_ladder": self.build_ladder(0, len(cleaned_questions)),
924
  "current_prize": self.prize_for_index(0, len(cleaned_questions)),
@@ -997,40 +1022,39 @@ class BadgeGameManager:
997
  self,
998
  answer: Any,
999
  question: Dict[str, Any],
1000
- ) -> str:
 
 
1001
  if isinstance(answer, dict):
1002
- answer = (
1003
- answer.get("value")
1004
- or answer.get("text")
1005
- or answer.get("label")
1006
- or answer.get("key")
1007
- or ""
1008
- )
 
 
 
 
 
 
1009
 
1010
  text = str(answer or "").strip()
1011
  if not text:
1012
  return ""
1013
-
1014
  if self.is_unknown(text):
1015
  return self.UNKNOWN_LABEL
1016
-
1017
  normalized = self.normalize_answer(text)
1018
-
1019
  for button in question.get("option_buttons", []) or []:
1020
  if not isinstance(button, dict):
1021
  continue
1022
-
1023
  candidates = [
1024
- button.get("value"),
1025
- button.get("text"),
1026
- button.get("label"),
1027
- button.get("key"),
1028
- button.get("arabic_key"),
1029
  ]
1030
-
1031
  if normalized in {self.normalize_answer(item) for item in candidates}:
1032
  return str(button.get("value") or button.get("text") or text)
1033
-
1034
  return self._strip_choice_prefix(text)
1035
 
1036
  def _register_error(
@@ -1178,14 +1202,28 @@ class BadgeGameManager:
1178
  "version": self.version,
1179
  }
1180
 
1181
- correct = str(
1182
- current_question.get("answer")
1183
- or current_question.get("correct_answer")
1184
- or ""
1185
- ).strip()
1186
 
1187
- unknown = self.is_unknown(selected)
1188
- is_correct = False if unknown else self.answer_matches(selected, correct)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1189
 
1190
  if is_correct:
1191
  answer_type = "correct"
@@ -1209,8 +1247,10 @@ class BadgeGameManager:
1209
  "question_id": current_question.get("question_id", ""),
1210
  "prompt": current_question.get("prompt", ""),
1211
  "selected": selected,
1212
- "selected_raw": str(answer or ""),
1213
  "correct": correct,
 
 
1214
  "is_correct": is_correct,
1215
  "answer_type": answer_type,
1216
  "diagnostic_status": answer_type,
@@ -1681,7 +1721,7 @@ class BadgeGameManager:
1681
  "level_test_is_separate": False,
1682
  "prize_ladder_top": 1000000,
1683
  "question_display": "question_only_without_duplicate_options",
1684
- "answer_options": 4,
1685
  "unknown_is_separate_button": True,
1686
  "unknown_label": self.UNKNOWN_LABEL,
1687
  "help_trigger": {
@@ -1710,10 +1750,7 @@ class BadgeGameManager:
1710
  "request_help",
1711
  "summary",
1712
  ],
1713
- "message": (
1714
- "BadgeGameManager جاهز بنمط المسابقات: أربعة اختيارات، "
1715
- "زر لا أعلم مستقل، ومساعدة بعد تكرار الخطأ."
1716
- ),
1717
  }
1718
 
1719
 
 
725
  ) -> Dict[str, Any]:
726
  q = dict(question) if isinstance(question, dict) else {}
727
  badge_id = self.normalize_badge_id(q.get("badge_id") or badge_id)
 
728
  prompt = str(
729
+ q.get("prompt") or q.get("question") or q.get("text") or "أجب عن السؤال."
 
 
 
730
  ).strip()
731
+ item_type = str(
732
+ q.get("item_type") or q.get("question_type") or "multiple_choice"
733
+ ).strip().lower()
734
 
735
  raw_options = q.get("options") or q.get("choices") or []
736
  if isinstance(raw_options, dict):
 
738
  if not isinstance(raw_options, list):
739
  raw_options = [raw_options]
740
 
741
+ correct = q.get("correct_response", q.get("correct_answer", q.get("answer")))
742
+ option_texts: List[str] = []
743
+ labeled_options: List[str] = []
744
+ option_buttons: List[Dict[str, Any]] = []
745
+
746
+ # أزرار المليون تستخدم فقط للأنواع ذات الإجابة الواحدة.
747
+ if item_type in {"multiple_choice", "true_false"}:
748
+ answer_text = self._strip_choice_prefix(correct)
749
+ option_texts = self._unique_texts(raw_options)
750
+ if answer_text and self.normalize_answer(answer_text) not in {
751
+ self.normalize_answer(item) for item in option_texts
752
+ }:
753
+ option_texts.append(answer_text)
754
+ minimum = 2 if item_type == "true_false" else 4
755
+ if item_type == "multiple_choice":
756
+ option_texts = self._ensure_four_options(prompt, option_texts, answer_text)
757
+ else:
758
+ option_texts = option_texts[:2] or ["صح", "خطأ"]
759
+ if shuffle_options and item_type == "multiple_choice":
760
+ random.shuffle(option_texts)
761
+ labeled_options, option_buttons = self._label_options(option_texts)
762
+ else:
763
+ answer_text = correct
764
 
765
  cleaned = dict(q)
766
  cleaned.update(
767
  {
768
  "question_id": str(
769
+ q.get("question_id") or f"{badge_id}_{uuid.uuid4().hex[:10]}"
 
770
  ),
771
  "badge_id": badge_id,
772
  "prompt": prompt,
773
  "question": prompt,
774
  "text": prompt,
775
+ "item_type": item_type,
776
+ "question_type": item_type,
777
+ "answer": correct,
778
+ "correct_answer": correct,
779
+ "correct_response": correct,
780
  "option_texts": option_texts,
781
+ "options": labeled_options if option_buttons else raw_options,
782
+ "choices": labeled_options if option_buttons else raw_options,
783
  "option_buttons": option_buttons,
784
  "unknown_option": {
785
  "id": "unknown",
 
789
  "separate": True,
790
  },
791
  "unknown_label": self.UNKNOWN_LABEL,
792
+ "game_style": "multi_type_mastery",
793
  "answer_locked": False,
794
  }
795
  )
 
796
  cleaned.setdefault("subject", "math")
797
  cleaned.setdefault("subject_name", "رياضيات")
798
  cleaned.setdefault("skill_id", badge_id)
 
801
  cleaned.setdefault("difficulty", 1)
802
  cleaned.setdefault("error_type_if_wrong", "unknown_error")
803
  cleaned.setdefault("explanation", "")
804
+ cleaned.setdefault("allow_dont_know", True)
805
+ cleaned.setdefault("confirm_before_submit", True)
806
  return cleaned
807
 
808
  def question_text(self, question: Dict[str, Any], index: int = 0, total: int = 0) -> str:
809
  prompt = str(
810
+ question.get("prompt") or question.get("question") or question.get("text") or ""
 
 
 
811
  ).strip()
812
+ item_type = str(question.get("item_type") or "multiple_choice")
813
+ instruction = {
814
+ "multiple_choice": "اختر إجابتك من الأزرار، أو اضغط لا أعلم.",
815
+ "true_false": "اختر صح أو خطأ، أو اضغط لا أعلم.",
816
+ "multi_select": "اختر جميع الإجابات الصحيحة من أداة السؤال ثم أكّد.",
817
+ "numeric_input": "اكتب الإجابة الرقمية في أداة السؤال ثم أكّد.",
818
+ "fill_blank": "أكمل الفراغ في أداة السؤال ثم أكّد.",
819
+ "ordering": "رتّب العناصر في أداة السؤال ثم أكّد.",
820
+ "matching": "نفّذ المطابقة في أداة السؤال ثم أكّد.",
821
+ "drag_drop": "اسحب العناصر إلى أماكنها ثم أكّد.",
822
+ "short_answer": "اكتب إجابة قصيرة ثم أكّد.",
823
+ "oral_response": "قل الإجابة أو اكتبها عند تعذر الميكروفون.",
824
+ "number_line": "حدّد القيمة المطلوبة على خط الأعداد.",
825
+ "graph_interaction": "استخدم الرسم أو اكتب النقطة المطلوبة.",
826
+ "whiteboard_response": "حل أو ارسم على السبورة ثم أكّد.",
827
+ "interactive_game": "نفّذ النشاط التفاعلي ثم أكّد.",
828
+ }.get(item_type, "أجب في أداة السؤال ثم أكّد.")
829
  return (
830
  f"السؤال {index + 1} من {total}\n\n"
831
  f"{prompt}\n\n"
832
+ f"{instruction}"
833
  ).strip()
834
 
835
  def build_question_ui(
 
943
  "skill_title": cleaned_questions[0].get("skill_title", (badge or {}).get("skill_title", "")),
944
  "mastery_scope": "exact_skill" if (exact_skill_id or str(badge_id).startswith("DIVID-MATH-")) else "legacy_badge",
945
  "status": "active",
946
+ "game_style": "multi_type_mastery",
947
  "assessment_mode": "exact_skill_mastery_check",
948
  "millionaire_ladder": self.build_ladder(0, len(cleaned_questions)),
949
  "current_prize": self.prize_for_index(0, len(cleaned_questions)),
 
1022
  self,
1023
  answer: Any,
1024
  question: Dict[str, Any],
1025
+ ) -> Any:
1026
+ if isinstance(answer, list):
1027
+ return answer
1028
  if isinstance(answer, dict):
1029
+ # استجابة السبورة أو المطابقة تبقى كما هي للتصحيح المركزي.
1030
+ if "answer" in answer or answer.get("type") == "dont_know":
1031
+ return answer
1032
+ if any(key in answer for key in ["value", "text", "label", "key"]):
1033
+ answer = (
1034
+ answer.get("value")
1035
+ or answer.get("text")
1036
+ or answer.get("label")
1037
+ or answer.get("key")
1038
+ or ""
1039
+ )
1040
+ else:
1041
+ return answer
1042
 
1043
  text = str(answer or "").strip()
1044
  if not text:
1045
  return ""
 
1046
  if self.is_unknown(text):
1047
  return self.UNKNOWN_LABEL
 
1048
  normalized = self.normalize_answer(text)
 
1049
  for button in question.get("option_buttons", []) or []:
1050
  if not isinstance(button, dict):
1051
  continue
 
1052
  candidates = [
1053
+ button.get("value"), button.get("text"), button.get("label"),
1054
+ button.get("key"), button.get("arabic_key"),
 
 
 
1055
  ]
 
1056
  if normalized in {self.normalize_answer(item) for item in candidates}:
1057
  return str(button.get("value") or button.get("text") or text)
 
1058
  return self._strip_choice_prefix(text)
1059
 
1060
  def _register_error(
 
1202
  "version": self.version,
1203
  }
1204
 
1205
+ correct = current_question.get(
1206
+ "correct_response",
1207
+ current_question.get("correct_answer", current_question.get("answer")),
1208
+ )
 
1209
 
1210
+ unknown = (
1211
+ (isinstance(selected, dict) and selected.get("type") == "dont_know")
1212
+ or (not isinstance(selected, (dict, list)) and self.is_unknown(selected))
1213
+ )
1214
+ scoring_result = {}
1215
+ if unknown:
1216
+ is_correct = False
1217
+ else:
1218
+ try:
1219
+ from question_bank_manager import get_manager as get_question_bank_manager
1220
+ scoring_result = get_question_bank_manager().score_response(
1221
+ current_question,
1222
+ selected,
1223
+ )
1224
+ is_correct = scoring_result.get("is_correct") is True
1225
+ except Exception:
1226
+ is_correct = self.answer_matches(selected, correct)
1227
 
1228
  if is_correct:
1229
  answer_type = "correct"
 
1247
  "question_id": current_question.get("question_id", ""),
1248
  "prompt": current_question.get("prompt", ""),
1249
  "selected": selected,
1250
+ "selected_raw": json.dumps(answer, ensure_ascii=False, default=str) if isinstance(answer, (dict, list)) else str(answer or ""),
1251
  "correct": correct,
1252
+ "item_type": current_question.get("item_type", "multiple_choice"),
1253
+ "scoring_result": scoring_result,
1254
  "is_correct": is_correct,
1255
  "answer_type": answer_type,
1256
  "diagnostic_status": answer_type,
 
1721
  "level_test_is_separate": False,
1722
  "prize_ladder_top": 1000000,
1723
  "question_display": "question_only_without_duplicate_options",
1724
+ "answer_options": "dynamic_by_item_type",
1725
  "unknown_is_separate_button": True,
1726
  "unknown_label": self.UNKNOWN_LABEL,
1727
  "help_trigger": {
 
1750
  "request_help",
1751
  "summary",
1752
  ],
1753
+ "message": "BadgeGameManager جاهز للأسئلة متعددة الأنواع مع زر لا أعلم والتوقف عند أول خطأ مؤكد.",
 
 
 
1754
  }
1755
 
1756
 
deepmind_compatible_generator.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # deepmind_compatible_generator.py
2
+ #!/usr/bin/env python3
3
+ # -*- coding: utf-8 -*-
4
+ """
5
+ مولد محلي متوافق مع عائلات Google DeepMind Mathematics Dataset.
6
+ لا ينسخ كود المنبع؛ ينفذ مولدات بسيطة مستقلة ويثبت المصدر والترخيص والـ seed.
7
+ License notice for upstream method reference: Apache-2.0.
8
+ """
9
+ from __future__ import annotations
10
+ import random
11
+ from fractions import Fraction
12
+
13
+ VERSION = "deepmind_compatible_generator_v1"
14
+
15
+ def generate_linear_equation(seed: int = 0):
16
+ rng = random.Random(seed)
17
+ x = rng.randint(-20, 30)
18
+ a = rng.choice([n for n in range(-9, 10) if n])
19
+ b = rng.randint(-30, 30)
20
+ c = a * x + b
21
+ return {
22
+ "module": "algebra/linear_1d",
23
+ "seed": seed,
24
+ "question_ar": f"حل المعادلة: {a}س + ({b}) = {c}",
25
+ "correct_response": x,
26
+ }
27
+
28
+ def generate_arithmetic(seed: int = 0):
29
+ rng = random.Random(seed)
30
+ a = rng.randint(10, 900)
31
+ b = rng.randint(2, 99)
32
+ c = rng.randint(2, 20)
33
+ return {
34
+ "module": "arithmetic/mixed",
35
+ "seed": seed,
36
+ "question_ar": f"احسب: {a} + {b} × {c}",
37
+ "correct_response": a + b * c,
38
+ }
39
+
40
+ def generate_probability(seed: int = 0):
41
+ rng = random.Random(seed)
42
+ red = rng.randint(2, 8)
43
+ blue = rng.randint(2, 8)
44
+ total = red + blue
45
+ answer = Fraction(red * (red - 1), total * (total - 1))
46
+ return {
47
+ "module": "probability/sampling_without_replacement",
48
+ "seed": seed,
49
+ "question_ar": (
50
+ f"في كيس {red} كرات حمراء و{blue} زرقاء. "
51
+ "ما احتمال سحب كرتين حمراوين دون إرجاع؟"
52
+ ),
53
+ "correct_response": str(answer),
54
+ }
external_question_import_manager.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # external_question_import_manager.py
2
+ #!/usr/bin/env python3
3
+ # -*- coding: utf-8 -*-
4
+
5
+ from __future__ import annotations
6
+ import hashlib
7
+ import json
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ VERSION = "external_question_import_manager_v1"
12
+
13
+ class ExternalQuestionImportManager:
14
+ def __init__(self, root_dir: Optional[str | Path] = None) -> None:
15
+ self.root_dir = Path(root_dir or Path(__file__).resolve().parent)
16
+ self.sources_path = self.root_dir / "assessment_source_registry.json"
17
+ self.items_path = self.root_dir / "external_assessment_items.json"
18
+ self.policy_path = self.root_dir / "question_import_policy.json"
19
+ self.reload()
20
+
21
+ def reload(self) -> Dict[str, Any]:
22
+ self.sources_data = json.loads(self.sources_path.read_text(encoding="utf-8"))
23
+ self.items_data = json.loads(self.items_path.read_text(encoding="utf-8"))
24
+ self.policy = json.loads(self.policy_path.read_text(encoding="utf-8"))
25
+ self.sources = {str(x["source_id"]): x for x in self.sources_data.get("sources", [])}
26
+ return self.summary()
27
+
28
+ @staticmethod
29
+ def _id(source_id: str, source_item_id: str, question: str) -> str:
30
+ digest = hashlib.sha256(f"{source_id}|{source_item_id}|{question}".encode("utf-8")).hexdigest()[:18]
31
+ return f"EXT-{source_id}-{digest}"
32
+
33
+ def validate_item(self, item: Dict[str, Any], commercial_mode: bool = True) -> Dict[str, Any]:
34
+ errors: List[str] = []
35
+ required = self.policy.get("required_item_fields", [])
36
+ for field in required:
37
+ if field not in item or item.get(field) in (None, ""):
38
+ errors.append(f"missing:{field}")
39
+ source_id = str(item.get("source_id") or "")
40
+ source = self.sources.get(source_id)
41
+ if not source:
42
+ errors.append("unknown_source_id")
43
+ else:
44
+ if commercial_mode and item.get("commercial_use_allowed") is False:
45
+ errors.append("noncommercial_item_in_commercial_mode")
46
+ license_text = str(item.get("license") or "").upper()
47
+ if "ND" in license_text and str(item.get("adapted_or_verbatim")) != "verbatim":
48
+ errors.append("no_derivatives_license_blocks_adaptation")
49
+ if source.get("production_action") in {"reference_alignment_only", "alignment_reference_only", "no_import_without_contract", "link_or_reference_only"}:
50
+ errors.append("source_not_approved_for_item_import")
51
+ if item.get("skill_id") in (None, ""):
52
+ errors.append("missing_skill_mapping")
53
+ if item.get("status") == "approved" and item.get("translation_status") not in {"not_needed", "human_reviewed", "approved"}:
54
+ errors.append("translation_not_approved")
55
+ return {"success": not errors, "errors": errors, "version": VERSION}
56
+
57
+ def stage_item(self, item: Dict[str, Any]) -> Dict[str, Any]:
58
+ record = dict(item)
59
+ record.setdefault("external_item_id", self._id(str(record.get("source_id")), str(record.get("source_item_id")), str(record.get("question"))))
60
+ record["status"] = "staging"
61
+ validation = self.validate_item(record, commercial_mode=False)
62
+ record["validation"] = validation
63
+ self.items_data.setdefault("staging_items", []).append(record)
64
+ self._save()
65
+ return record
66
+
67
+ def approve_item(self, external_item_id: str, commercial_mode: bool = True) -> Dict[str, Any]:
68
+ staging = self.items_data.get("staging_items", [])
69
+ for index, record in enumerate(staging):
70
+ if str(record.get("external_item_id")) != str(external_item_id):
71
+ continue
72
+ candidate = dict(record)
73
+ candidate["status"] = "approved"
74
+ validation = self.validate_item(candidate, commercial_mode=commercial_mode)
75
+ candidate["validation"] = validation
76
+ if not validation["success"]:
77
+ return {"success": False, "record": candidate, "errors": validation["errors"]}
78
+ self.items_data.setdefault("items", []).append(candidate)
79
+ staging.pop(index)
80
+ self._save()
81
+ return {"success": True, "record": candidate}
82
+ return {"success": False, "errors": ["item_not_found"]}
83
+
84
+ def _save(self) -> None:
85
+ self.items_data["approved_items_count"] = len(self.items_data.get("items", []))
86
+ self.items_data["staging_items_count"] = len(self.items_data.get("staging_items", []))
87
+ self.items_path.write_text(json.dumps(self.items_data, ensure_ascii=False, indent=2), encoding="utf-8")
88
+
89
+ def summary(self) -> Dict[str, Any]:
90
+ return {
91
+ "version": VERSION,
92
+ "sources_count": len(self.sources),
93
+ "approved_items_count": len(self.items_data.get("items", [])),
94
+ "staging_items_count": len(self.items_data.get("staging_items", [])),
95
+ }
96
+
97
+ _MANAGER: Optional[ExternalQuestionImportManager] = None
98
+
99
+ def get_manager() -> ExternalQuestionImportManager:
100
+ global _MANAGER
101
+ if _MANAGER is None:
102
+ _MANAGER = ExternalQuestionImportManager()
103
+ return _MANAGER
question_bank_manager.py CHANGED
@@ -15,7 +15,12 @@ from fractions import Fraction
15
  from pathlib import Path
16
  from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
17
 
18
- VERSION = "question_bank_manager_v7_student_k12_registry_runtime"
 
 
 
 
 
19
 
20
 
21
  def _read_json(path: Path) -> Dict[str, Any]:
@@ -58,6 +63,8 @@ class QuestionBankManager:
58
  self.whiteboard_registry_data: Dict[str, Any] = {}
59
  self.whiteboard_features_by_skill: Dict[str, List[str]] = {}
60
  self.whiteboard_feature_records: Dict[str, Dict[str, Any]] = {}
 
 
61
  self.reload()
62
 
63
  def reload(self) -> Dict[str, Any]:
@@ -96,6 +103,15 @@ class QuestionBankManager:
96
  key = str(skill_id or "").strip()
97
  if key:
98
  self.whiteboard_features_by_skill.setdefault(key, []).append(feature_id)
 
 
 
 
 
 
 
 
 
99
  return self.summary()
100
 
101
 
@@ -914,6 +930,19 @@ class QuestionBankManager:
914
  "template_id":tpl.get("template_id"),
915
  "skill_id":skill_id,
916
  "skill_title":skill.get("title",{}).get("ar"),
 
 
 
 
 
 
 
 
 
 
 
 
 
917
  "badge_id":(skill.get("badge_ids") or [""])[0],
918
  "badge_ids":skill.get("badge_ids",[]),
919
  "grade_range":skill.get("grade_range",{}),
@@ -953,6 +982,65 @@ class QuestionBankManager:
953
  if not validation["success"]: result["success"]=False
954
  return result
955
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
956
  def get_questions_for_skill(self, skill_id: str, count: int = 5, grade: Optional[int] = None, difficulty: Optional[int] = None, seed: Any = None, usage_context: Optional[str] = None, exclude_question_ids: Optional[Iterable[str]] = None, exclude_question_texts: Optional[Iterable[str]] = None, **kwargs: Any) -> List[Dict[str, Any]]:
957
  count=max(1,min(int(count or 5),100)); excluded_ids={str(x) for x in (exclude_question_ids or [])}; excluded_texts={str(x).strip() for x in (exclude_question_texts or [])}
958
  templates=self.templates_by_skill.get(str(skill_id),[])
@@ -1171,13 +1259,948 @@ class QuestionBankManager:
1171
  "message":"QuestionBankManager جاهز: سجل السبورة يختار جميع أدوات الروضة حتى الصف الثالث من الجذر.",
1172
  }
1173
 
1174
- _MANAGER: Optional[QuestionBankManager] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1175
  _LOCK=threading.Lock()
1176
 
1177
- def get_manager() -> QuestionBankManager:
1178
  global _MANAGER
1179
  with _LOCK:
1180
- if _MANAGER is None: _MANAGER=QuestionBankManager()
1181
  return _MANAGER
1182
 
1183
  def get_badge_diagnostic_questions(subject="math",grade=None,badge_id="",count=5,difficulty=None,skill_id="",**kwargs):
@@ -1192,6 +2215,27 @@ def get_skills_for_badge(badge_id, grade=None):
1192
  def get_mastery_units(grade=None, learning_mode="gap_recovery"):
1193
  return get_manager().get_mastery_units(grade=grade, learning_mode=learning_mode)
1194
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1195
  def get_questions_for_badge(badge_id,count=5,**kwargs):
1196
  return get_manager().get_questions_for_badge(badge_id,count=count,**kwargs)
1197
 
@@ -1212,5 +2256,20 @@ def get_whiteboard_task_for_feature(feature_id, **kwargs):
1212
  def get_whiteboard_task_for_skill(skill_id, **kwargs):
1213
  return get_manager().get_whiteboard_task_for_skill(skill_id, **kwargs)
1214
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1215
  def summary():
1216
  return get_manager().summary()
 
15
  from pathlib import Path
16
  from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
17
 
18
+ try:
19
+ from question_clarification_manager import QuestionClarificationManager
20
+ except Exception:
21
+ QuestionClarificationManager = None
22
+
23
+ VERSION = "question_bank_manager_v11_math_concepts_clarification"
24
 
25
 
26
  def _read_json(path: Path) -> Dict[str, Any]:
 
63
  self.whiteboard_registry_data: Dict[str, Any] = {}
64
  self.whiteboard_features_by_skill: Dict[str, List[str]] = {}
65
  self.whiteboard_feature_records: Dict[str, Dict[str, Any]] = {}
66
+ self.clarification_manager = None
67
+ self.clarification_error = ""
68
  self.reload()
69
 
70
  def reload(self) -> Dict[str, Any]:
 
103
  key = str(skill_id or "").strip()
104
  if key:
105
  self.whiteboard_features_by_skill.setdefault(key, []).append(feature_id)
106
+ if QuestionClarificationManager is not None:
107
+ try:
108
+ self.clarification_manager = QuestionClarificationManager(
109
+ root_dir=self.root_dir
110
+ )
111
+ self.clarification_error = ""
112
+ except Exception as exc:
113
+ self.clarification_manager = None
114
+ self.clarification_error = repr(exc)
115
  return self.summary()
116
 
117
 
 
930
  "template_id":tpl.get("template_id"),
931
  "skill_id":skill_id,
932
  "skill_title":skill.get("title",{}).get("ar"),
933
+ "concept_ids":skill.get("concept_ids",[]),
934
+ "symbol_ids":skill.get("symbol_ids",[]),
935
+ "clarification_available":True,
936
+ "clarification_scored":False,
937
+ "clarification_is_hint":False,
938
+ "clarification_contract":{
939
+ "may_rephrase_question":True,
940
+ "may_explain_symbols":True,
941
+ "may_show_concept_aliases":True,
942
+ "hide_target_concept_definition":True,
943
+ "does_not_reduce_score":True,
944
+ "does_not_add_attempt":True,
945
+ },
946
  "badge_id":(skill.get("badge_ids") or [""])[0],
947
  "badge_ids":skill.get("badge_ids",[]),
948
  "grade_range":skill.get("grade_range",{}),
 
982
  if not validation["success"]: result["success"]=False
983
  return result
984
 
985
+ def clarify_question(
986
+ self,
987
+ question: Dict[str, Any] | str,
988
+ assessed_concept_ids: Optional[Iterable[str]] = None,
989
+ assessed_symbol_ids: Optional[Iterable[str]] = None,
990
+ assessment_mode: bool = True,
991
+ ) -> Dict[str, Any]:
992
+ if self.clarification_manager is None:
993
+ return {
994
+ "success": False,
995
+ "message": "مدير توضيح السؤال غير متاح.",
996
+ "error": self.clarification_error,
997
+ "evaluation_effect": {
998
+ "scored": False,
999
+ "counts_as_hint": False,
1000
+ },
1001
+ }
1002
+ if isinstance(question, dict):
1003
+ question_text = str(
1004
+ question.get("question")
1005
+ or question.get("prompt")
1006
+ or question.get("question_ar")
1007
+ or ""
1008
+ )
1009
+ skill_id = str(question.get("skill_id") or "")
1010
+ if assessed_concept_ids is None:
1011
+ assessed_concept_ids = question.get("assessed_concept_ids") or []
1012
+ if assessed_symbol_ids is None:
1013
+ assessed_symbol_ids = question.get("assessed_symbol_ids") or []
1014
+ else:
1015
+ question_text = str(question or "")
1016
+ skill_id = ""
1017
+ return self.clarification_manager.clarify_question(
1018
+ question_text=question_text,
1019
+ skill_id=skill_id,
1020
+ assessed_concept_ids=assessed_concept_ids,
1021
+ assessed_symbol_ids=assessed_symbol_ids,
1022
+ assessment_mode=assessment_mode,
1023
+ )
1024
+
1025
+ def get_concept_dictionary_entry(self, concept_id: str) -> Dict[str, Any]:
1026
+ if self.clarification_manager is None:
1027
+ return {}
1028
+ return self.clarification_manager.get_concept(concept_id)
1029
+
1030
+ def get_symbol_dictionary_entry(self, symbol_id: str) -> Dict[str, Any]:
1031
+ if self.clarification_manager is None:
1032
+ return {}
1033
+ return self.clarification_manager.get_symbol(symbol_id)
1034
+
1035
+ def get_remedial_concept_package(self, skill_id: str) -> Dict[str, Any]:
1036
+ if self.clarification_manager is None:
1037
+ return {
1038
+ "success": False,
1039
+ "skill_id": skill_id,
1040
+ "message": "قاموس المفاهيم غير متاح.",
1041
+ }
1042
+ return self.clarification_manager.get_remedial_concept_package(skill_id)
1043
+
1044
  def get_questions_for_skill(self, skill_id: str, count: int = 5, grade: Optional[int] = None, difficulty: Optional[int] = None, seed: Any = None, usage_context: Optional[str] = None, exclude_question_ids: Optional[Iterable[str]] = None, exclude_question_texts: Optional[Iterable[str]] = None, **kwargs: Any) -> List[Dict[str, Any]]:
1045
  count=max(1,min(int(count or 5),100)); excluded_ids={str(x) for x in (exclude_question_ids or [])}; excluded_texts={str(x).strip() for x in (exclude_question_texts or [])}
1046
  templates=self.templates_by_skill.get(str(skill_id),[])
 
1259
  "message":"QuestionBankManager جاهز: سجل السبورة يختار جميع أدوات الروضة حتى الصف الثالث من الجذر.",
1260
  }
1261
 
1262
+
1263
+ class ComprehensiveQuestionBankManager(QuestionBankManager):
1264
+ """النسخة الإنتاجية متعددة الأنواع فوق مولدات البنك الأصلية."""
1265
+
1266
+ def reload(self) -> Dict[str, Any]:
1267
+ self.question_type_registry = {"types": []}
1268
+ self.assessment_source_registry = {"sources": []}
1269
+ self.external_items_data = {"items": []}
1270
+ self.external_items_by_skill = {}
1271
+ self.question_type_records = {}
1272
+ self.bank_coverage = {
1273
+ "skills_total": 0,
1274
+ "skills_with_templates": 0,
1275
+ "skills_without_templates": [],
1276
+ "templates_total": 0,
1277
+ }
1278
+ result = super().reload()
1279
+ self.question_type_registry_path = self.root_dir / "question_type_registry.json"
1280
+ self.assessment_source_registry_path = self.root_dir / "assessment_source_registry.json"
1281
+ self.external_items_path = self.root_dir / "external_assessment_items.json"
1282
+ self.challenge_policy_path = self.root_dir / "challenge_test_policy.json"
1283
+ try:
1284
+ self.question_type_registry = _read_json(self.question_type_registry_path)
1285
+ except Exception:
1286
+ self.question_type_registry = {"types": []}
1287
+ try:
1288
+ self.assessment_source_registry = _read_json(self.assessment_source_registry_path)
1289
+ except Exception:
1290
+ self.assessment_source_registry = {"sources": []}
1291
+ try:
1292
+ self.external_items_data = _read_json(self.external_items_path)
1293
+ except Exception:
1294
+ self.external_items_data = {"items": []}
1295
+ try:
1296
+ self.challenge_policy = _read_json(self.challenge_policy_path)
1297
+ except Exception:
1298
+ self.challenge_policy = {
1299
+ "default_question_count": 20,
1300
+ "difficulty_distribution": {"3": 0.20, "4": 0.45, "5": 0.35},
1301
+ }
1302
+ self.external_items_by_skill = {}
1303
+ for item in self.external_items_data.get("items", []):
1304
+ if not isinstance(item, dict) or item.get("status") != "approved":
1305
+ continue
1306
+ skill_id = str(item.get("skill_id") or "").strip()
1307
+ if skill_id:
1308
+ self.external_items_by_skill.setdefault(skill_id, []).append(dict(item))
1309
+
1310
+ self.question_type_records = {
1311
+ str(item.get("item_type")): dict(item)
1312
+ for item in self.question_type_registry.get("types", [])
1313
+ if isinstance(item, dict) and item.get("item_type")
1314
+ }
1315
+ self.bank_coverage = {
1316
+ "skills_total": len(self.skills),
1317
+ "skills_with_templates": len(self.templates_by_skill),
1318
+ "skills_without_templates": sorted(set(self.skills) - set(self.templates_by_skill)),
1319
+ "templates_total": sum(len(v) for v in self.templates_by_skill.values()),
1320
+ }
1321
+ return result
1322
+
1323
+ @staticmethod
1324
+ def _normalize_text(value: Any) -> str:
1325
+ if value is None:
1326
+ return ""
1327
+ text = str(value).strip().lower()
1328
+ text = text.translate(str.maketrans("٠١٢٣٤٥٦٧٨٩", "0123456789"))
1329
+ text = text.replace("،", ",").replace("٫", ".")
1330
+ text = re.sub(r"\s+", " ", text)
1331
+ return text
1332
+
1333
+ @classmethod
1334
+ def _numeric_value(cls, value: Any) -> Optional[float]:
1335
+ if isinstance(value, dict):
1336
+ value = value.get("value", value.get("raw"))
1337
+ text = cls._normalize_text(value).replace(",", ".")
1338
+ try:
1339
+ return float(text)
1340
+ except Exception:
1341
+ return None
1342
+
1343
+ @classmethod
1344
+ def _normalize_collection(cls, value: Any) -> List[str]:
1345
+ if value is None:
1346
+ return []
1347
+ if not isinstance(value, list):
1348
+ value = [value]
1349
+ return [cls._normalize_text(item) for item in value]
1350
+
1351
+ @classmethod
1352
+ def _normalize_mapping(cls, value: Any) -> Dict[str, List[str]]:
1353
+ if not isinstance(value, dict):
1354
+ return {}
1355
+ return {
1356
+ cls._normalize_text(key): sorted(cls._normalize_collection(items))
1357
+ for key, items in value.items()
1358
+ }
1359
+
1360
+ def _equivalent_variant(self, correct: str) -> str:
1361
+ text = self._normalize_text(correct)
1362
+ try:
1363
+ value = Fraction(text)
1364
+ if value.denominator == 1:
1365
+ return f"{value.numerator}/1"
1366
+ return f"{value.numerator * 2}/{value.denominator * 2}"
1367
+ except Exception:
1368
+ pass
1369
+ try:
1370
+ value = float(text)
1371
+ if abs(value - round(value)) < 1e-9:
1372
+ return f"{int(round(value))}.0"
1373
+ return f"{value:.4f}".rstrip("0")
1374
+ except Exception:
1375
+ return f"({correct})"
1376
+
1377
+ def _clean_choices(self, choices: Sequence[Any], correct: Any, rng: random.Random) -> List[str]:
1378
+ out: List[str] = []
1379
+ for item in list(choices or []):
1380
+ text = _fmt(item)
1381
+ if text not in out:
1382
+ out.append(text)
1383
+ correct_text = _fmt(correct)
1384
+ if correct_text not in out:
1385
+ out.insert(0, correct_text)
1386
+ while len(out) < 4:
1387
+ filler = f"بديل {len(out) + 1}"
1388
+ if filler not in out:
1389
+ out.append(filler)
1390
+ out = out[:4]
1391
+ rng.shuffle(out)
1392
+ return out
1393
+
1394
+ def _shape_question(
1395
+ self,
1396
+ *,
1397
+ skill: Dict[str, Any],
1398
+ tpl: Dict[str, Any],
1399
+ question: str,
1400
+ answer: Any,
1401
+ base_choices: Sequence[Any],
1402
+ meta: Dict[str, Any],
1403
+ rng: random.Random,
1404
+ sequence: int,
1405
+ ) -> Dict[str, Any]:
1406
+ item_type = str(tpl.get("item_type") or "multiple_choice").strip().lower()
1407
+ correct = _fmt(answer)
1408
+ requested_item_type = item_type
1409
+ if item_type in {"numeric_input", "number_line"} and self._numeric_value(correct) is None:
1410
+ item_type = "fill_blank" if item_type == "numeric_input" else "short_answer"
1411
+ choices = self._clean_choices(base_choices, correct, rng)
1412
+ prompt = str(question).strip()
1413
+ interaction_payload: Dict[str, Any] = {"requested_item_type": requested_item_type}
1414
+ response_schema: Dict[str, Any] = {"type": "string"}
1415
+ scoring_mode = str(tpl.get("scoring_mode") or "exact_match")
1416
+ correct_response: Any = correct
1417
+
1418
+ if item_type == "true_false":
1419
+ show_correct = (sequence % 2 == 0)
1420
+ proposed = correct if show_correct else next((x for x in choices if x != correct), f"ليس {correct}")
1421
+ prompt = f"صح أم خطأ: إجابة السؤال التالي هي «{proposed}»: {prompt}"
1422
+ choices = ["صح", "خطأ"]
1423
+ correct_response = "صح" if show_correct else "خطأ"
1424
+ response_schema = {"type": "string", "enum": choices}
1425
+ scoring_mode = "exact_match"
1426
+
1427
+ elif item_type == "numeric_input":
1428
+ choices = []
1429
+ response_schema = {"type": "number"}
1430
+ scoring_mode = "numeric_tolerance"
1431
+
1432
+ elif item_type == "fill_blank":
1433
+ prompt = f"{prompt}\nأكمل الفراغ: □"
1434
+ choices = []
1435
+ response_schema = {"type": "string"}
1436
+ scoring_mode = "normalized_text"
1437
+
1438
+ elif item_type in {"short_answer", "oral_response", "graph_interaction"}:
1439
+ choices = []
1440
+ response_schema = {"type": "string"}
1441
+ scoring_mode = "normalized_text"
1442
+ if item_type == "oral_response":
1443
+ prompt = f"{prompt}\nقل الإجابة شفهيًا، أو اكتبها عند تعذر الميكروفون."
1444
+ elif item_type == "graph_interaction":
1445
+ prompt = f"{prompt}\nحدّد القيمة أو النقطة المطلوبة من الرسم، ويمكنك استخدام السبورة."
1446
+
1447
+ elif item_type == "multi_select":
1448
+ equivalent = self._equivalent_variant(correct)
1449
+ distractors = [x for x in choices if x != correct and x != equivalent]
1450
+ multi_choices = [correct, equivalent] + distractors[:2]
1451
+ while len(multi_choices) < 4:
1452
+ multi_choices.append(f"بديل {len(multi_choices) + 1}")
1453
+ rng.shuffle(multi_choices)
1454
+ choices = multi_choices
1455
+ correct_response = [correct, equivalent]
1456
+ response_schema = {"type": "array", "items": {"type": "string"}, "minItems": 2}
1457
+ scoring_mode = "set_match"
1458
+ prompt = f"{prompt}\nاختر كل التمثيلات الصحيحة للإجابة."
1459
+
1460
+ elif item_type == "ordering":
1461
+ seq_values = meta.get("sequence") if isinstance(meta, dict) else None
1462
+ if isinstance(seq_values, list) and seq_values:
1463
+ ordered = [_fmt(x) for x in seq_values] + [correct]
1464
+ else:
1465
+ ordered = ["حدّد المعطيات", "اختر القاعدة المناسبة", "نفّذ الحل", "تحقق من النتيجة"]
1466
+ prompt = f"{prompt}\nرتّب خطوات الحل ترتيبًا صحيحًا."
1467
+ seen: List[str] = []
1468
+ for item in ordered:
1469
+ if item not in seen:
1470
+ seen.append(item)
1471
+ ordered = seen
1472
+ choices = list(ordered)
1473
+ rng.shuffle(choices)
1474
+ correct_response = ordered
1475
+ response_schema = {"type": "array", "items": {"type": "string"}}
1476
+ scoring_mode = "sequence_match"
1477
+
1478
+ elif item_type in {"matching", "drag_drop"}:
1479
+ distractors = [x for x in choices if x != correct]
1480
+ drag_items = [{"id": f"item_{i+1}", "label_ar": value, "value": value}
1481
+ for i, value in enumerate([correct] + distractors[:3])]
1482
+ correct_ids = [drag_items[0]["id"]]
1483
+ incorrect_ids = [item["id"] for item in drag_items[1:]]
1484
+ drop_zones = [
1485
+ {"id": "matches", "label_ar": "يحقق المطلوب"},
1486
+ {"id": "not_matches", "label_ar": "لا يحقق المطلوب"},
1487
+ ]
1488
+ correct_response = {"matches": correct_ids, "not_matches": incorrect_ids}
1489
+ interaction_payload = {
1490
+ "drag_items": drag_items,
1491
+ "drop_zones": drop_zones,
1492
+ }
1493
+ choices = []
1494
+ response_schema = {"type": "object"}
1495
+ scoring_mode = "pair_match"
1496
+ prompt = f"{prompt}\nصنّف البدائل بحسب تحققها من المطلوب."
1497
+
1498
+ elif item_type == "number_line":
1499
+ numeric = self._numeric_value(correct)
1500
+ choices = []
1501
+ interaction_payload = {
1502
+ "min_value": min(0, int(numeric or 0) - 5),
1503
+ "max_value": max(20, int(numeric or 0) + 5),
1504
+ "target_value": numeric,
1505
+ }
1506
+ response_schema = {"type": "number"}
1507
+ scoring_mode = "numeric_tolerance"
1508
+ prompt = f"{prompt}\nحدّد الإجابة على خط الأعداد."
1509
+
1510
+ elif item_type == "whiteboard_response":
1511
+ choices = []
1512
+ response_schema = {"type": "object"}
1513
+ scoring_mode = "whiteboard_evidence"
1514
+ prompt = f"{prompt}\nحل أو مثّل الإجابة على السبورة."
1515
+
1516
+ elif item_type == "interactive_game":
1517
+ choices = []
1518
+ response_schema = {"type": "object"}
1519
+ scoring_mode = "tool_state_match"
1520
+ prompt = f"{prompt}\nنفّذ المطلوب باستخدام أداة الرياضيات التفاعلية."
1521
+
1522
+ else:
1523
+ item_type = "multiple_choice"
1524
+ correct_response = correct
1525
+ response_schema = {"type": "string", "enum": choices}
1526
+ scoring_mode = "exact_match"
1527
+
1528
+ return {
1529
+ "item_type": item_type,
1530
+ "question_type": item_type,
1531
+ "prompt": prompt,
1532
+ "question": prompt,
1533
+ "question_ar": prompt,
1534
+ "choices": choices,
1535
+ "options": choices,
1536
+ "correct_answer": correct_response,
1537
+ "correct_response": correct_response,
1538
+ "answer": correct_response,
1539
+ "response_schema": response_schema,
1540
+ "interaction_payload": interaction_payload,
1541
+ "scoring_mode": scoring_mode,
1542
+ "numeric_tolerance": float(tpl.get("numeric_tolerance", 0) or 0),
1543
+ "partial_credit": False,
1544
+ "renderer": tpl.get("renderer") or self.question_type_records.get(item_type, {}).get("renderer", ""),
1545
+ "fallback_renderer": self.question_type_records.get(item_type, {}).get("fallback_renderer", ""),
1546
+ "mastery_eligible": bool(tpl.get("mastery_eligible", True)),
1547
+ }
1548
+
1549
+ def validate_question(self, q: Dict[str, Any]) -> Dict[str, Any]:
1550
+ problems: List[str] = []
1551
+ if not q.get("question_id"):
1552
+ problems.append("missing_question_id")
1553
+ if not q.get("question"):
1554
+ problems.append("missing_question")
1555
+ item_type = str(q.get("item_type") or "multiple_choice")
1556
+ correct = q.get("correct_response", q.get("correct_answer"))
1557
+ choices = q.get("choices") or []
1558
+
1559
+ if item_type in {"multiple_choice", "true_false"}:
1560
+ if not isinstance(choices, list) or len(choices) < 2:
1561
+ problems.append("choice_items_need_options")
1562
+ if correct not in choices:
1563
+ problems.append("answer_not_in_choices")
1564
+ elif item_type == "multi_select":
1565
+ if not isinstance(choices, list) or len(choices) < 2:
1566
+ problems.append("multi_select_needs_options")
1567
+ if not isinstance(correct, list) or not correct:
1568
+ problems.append("multi_select_needs_correct_set")
1569
+ elif any(item not in choices for item in correct):
1570
+ problems.append("multi_select_answer_not_in_choices")
1571
+ elif item_type == "ordering":
1572
+ if not isinstance(correct, list) or len(correct) < 2:
1573
+ problems.append("ordering_needs_sequence")
1574
+ elif item_type in {"matching", "drag_drop"}:
1575
+ if not isinstance(correct, dict) or not correct:
1576
+ problems.append("matching_needs_mapping")
1577
+ elif item_type not in self.question_type_records:
1578
+ problems.append("unregistered_item_type")
1579
+ if correct is None and item_type not in {"whiteboard_response", "interactive_game"}:
1580
+ problems.append("missing_correct_response")
1581
+ return {"success": not problems, "problems": problems}
1582
+
1583
+ def generate_question(
1584
+ self,
1585
+ skill_id: str,
1586
+ template: Optional[Dict[str, Any]] = None,
1587
+ seed: Any = None,
1588
+ sequence: int = 0,
1589
+ ) -> Dict[str, Any]:
1590
+ skill = self.skills.get(str(skill_id))
1591
+ if not skill:
1592
+ return {"success": False, "message": f"المهارة غير موجودة: {skill_id}"}
1593
+ templates = self.templates_by_skill.get(str(skill_id), [])
1594
+ if not templates:
1595
+ return {"success": False, "message": f"لا توجد قوالب: {skill_id}"}
1596
+ tpl = template or templates[sequence % len(templates)]
1597
+ rng = self._rng(seed if seed is not None else 0, skill_id, tpl.get("template_id"), sequence)
1598
+ question, answer, choices, meta = self._generate(
1599
+ tpl.get("generator", "concept_mcq"), skill, tpl, rng
1600
+ )
1601
+ shaped = self._shape_question(
1602
+ skill=skill,
1603
+ tpl=tpl,
1604
+ question=question,
1605
+ answer=answer,
1606
+ base_choices=choices,
1607
+ meta=meta,
1608
+ rng=rng,
1609
+ sequence=sequence,
1610
+ )
1611
+ qid_hash = hashlib.sha1(
1612
+ f"{skill_id}|{tpl.get('template_id')}|{seed}|{sequence}|{shaped['prompt']}|{shaped['correct_response']}".encode("utf-8")
1613
+ ).hexdigest()[:14]
1614
+ common_errors = skill.get("common_errors") or [{}]
1615
+ result = {
1616
+ "success": True,
1617
+ "question_id": f"Q-{qid_hash}",
1618
+ "id": f"Q-{qid_hash}",
1619
+ "question_kind": "diagnostic",
1620
+ "source": "divid_original_generator",
1621
+ "template_id": tpl.get("template_id"),
1622
+ "skill_id": skill_id,
1623
+ "skill_title": (skill.get("title") or {}).get("ar"),
1624
+ "concept_ids": skill.get("concept_ids", []),
1625
+ "symbol_ids": skill.get("symbol_ids", []),
1626
+ "clarification_available": True,
1627
+ "clarification_scored": False,
1628
+ "clarification_is_hint": False,
1629
+ "clarification_contract": {
1630
+ "may_rephrase_question": True,
1631
+ "may_explain_symbols": True,
1632
+ "may_show_concept_aliases": True,
1633
+ "hide_target_concept_definition": True,
1634
+ "does_not_reduce_score": True,
1635
+ "does_not_add_attempt": True,
1636
+ },
1637
+ "badge_id": (skill.get("badge_ids") or [""])[0],
1638
+ "badge_ids": skill.get("badge_ids", []),
1639
+ "grade_range": skill.get("grade_range", {}),
1640
+ "difficulty": int(tpl.get("difficulty", 3) or 3),
1641
+ "difficulty_source": tpl.get("difficulty_source", "skill_assessment_blueprint"),
1642
+ "cognitive_domain": tpl.get("cognitive_domain", "applying"),
1643
+ "usage_contexts": tpl.get("usage_contexts", []),
1644
+ "error_type_if_wrong": tpl.get("error_type_if_wrong")
1645
+ or common_errors[0].get("error_id", "NOT_FULL_MASTERY"),
1646
+ "diagnostic_signal": common_errors[0].get("diagnostic_signal", ""),
1647
+ "allow_dont_know": True,
1648
+ "confirm_before_submit": True,
1649
+ "stop_on_first_confirmed_error": True,
1650
+ "metadata": meta,
1651
+ "content_origin": "divid_original_generator",
1652
+ "license": "Divid Teacher Original Content",
1653
+ "permission_status": "owned_original",
1654
+ "source_item_id": f"Q-{qid_hash}",
1655
+ "attribution": "Divid Teacher",
1656
+ "commercial_use_allowed": True,
1657
+ "rights": {
1658
+ "origin": "divid_original_generator",
1659
+ "external_item_text_copied": False,
1660
+ "production_use": "approved",
1661
+ "license": "Divid Teacher Original Content",
1662
+ "permission_status": "owned_original",
1663
+ "commercial_use_allowed": True,
1664
+ },
1665
+ **shaped,
1666
+ }
1667
+ if isinstance(result.get("choices"), list) and result.get("correct_answer") in result.get("choices"):
1668
+ result["answer_index"] = result["choices"].index(result["correct_answer"])
1669
+ else:
1670
+ result["answer_index"] = -1
1671
+ validation = self.validate_question(result)
1672
+ result["validation"] = validation
1673
+ result["success"] = bool(validation["success"])
1674
+ return result
1675
+
1676
+ def score_response(self, question: Dict[str, Any], response: Any) -> Dict[str, Any]:
1677
+ q = dict(question or {})
1678
+ if isinstance(response, dict) and "answer" in response:
1679
+ board_evaluation = response.get("evaluation")
1680
+ if isinstance(board_evaluation, dict) and isinstance(board_evaluation.get("is_correct"), bool):
1681
+ return {
1682
+ "success": True,
1683
+ "is_correct": board_evaluation["is_correct"],
1684
+ "score": 1.0 if board_evaluation["is_correct"] else 0.0,
1685
+ "status": "correct" if board_evaluation["is_correct"] else "incorrect",
1686
+ "reason_ar": board_evaluation.get("reason_ar", ""),
1687
+ "scoring_mode": "board_evaluation",
1688
+ }
1689
+ response = response.get("answer")
1690
+
1691
+ if isinstance(response, dict) and response.get("type") == "dont_know":
1692
+ return {"success": True, "is_correct": False, "score": 0.0, "status": "unknown", "reason_ar": "اختار الطالب لا أعلم."}
1693
+
1694
+ mode = str(q.get("scoring_mode") or "exact_match")
1695
+ correct = q.get("correct_response", q.get("correct_answer"))
1696
+ ok: Optional[bool]
1697
+
1698
+ if mode == "numeric_tolerance":
1699
+ actual_num = self._numeric_value(response)
1700
+ correct_num = self._numeric_value(correct)
1701
+ tolerance = float(q.get("numeric_tolerance", 0) or 0)
1702
+ ok = (
1703
+ actual_num is not None
1704
+ and correct_num is not None
1705
+ and abs(actual_num - correct_num) <= tolerance
1706
+ )
1707
+ elif mode == "set_match":
1708
+ ok = sorted(self._normalize_collection(response)) == sorted(self._normalize_collection(correct))
1709
+ elif mode == "sequence_match":
1710
+ ok = self._normalize_collection(response) == self._normalize_collection(correct)
1711
+ elif mode == "pair_match":
1712
+ ok = self._normalize_mapping(response) == self._normalize_mapping(correct)
1713
+ elif mode in {"whiteboard_evidence", "tool_state_match", "rubric"}:
1714
+ return {
1715
+ "success": True,
1716
+ "is_correct": None,
1717
+ "score": None,
1718
+ "status": "pending_external",
1719
+ "reason_ar": "تحتاج الإجابة إلى مصحح السبورة أو مراجعة خارجية.",
1720
+ "scoring_mode": mode,
1721
+ }
1722
+ else:
1723
+ ok = self._normalize_text(response) == self._normalize_text(correct)
1724
+
1725
+ return {
1726
+ "success": True,
1727
+ "is_correct": bool(ok),
1728
+ "score": 1.0 if ok else 0.0,
1729
+ "status": "correct" if ok else "incorrect",
1730
+ "reason_ar": "" if ok else "الإجابة لا تطابق دليل الإتقان المطلوب.",
1731
+ "scoring_mode": mode,
1732
+ }
1733
+
1734
+ def question_to_whiteboard_task(
1735
+ self,
1736
+ question: Dict[str, Any],
1737
+ source: str = "platform",
1738
+ include_answer: bool = False,
1739
+ max_attempts: int = 1,
1740
+ stop_on_first_confirmed_error: bool = True,
1741
+ ) -> Dict[str, Any]:
1742
+ q = dict(question or {})
1743
+ prompt = str(q.get("prompt") or q.get("question") or "").strip()
1744
+ item_type = str(q.get("item_type") or q.get("question_type") or "multiple_choice")
1745
+ raw_choices = q.get("choices") or []
1746
+ options = [
1747
+ {"id": f"opt_{index+1}", "value": str(value), "label_ar": str(value)}
1748
+ for index, value in enumerate(raw_choices)
1749
+ ]
1750
+ payload = q.get("interaction_payload") if isinstance(q.get("interaction_payload"), dict) else {}
1751
+
1752
+ if item_type == "multiple_choice":
1753
+ response_mode = "single_choice"
1754
+ elif item_type == "multi_select":
1755
+ response_mode = "multiple_choice"
1756
+ elif item_type == "true_false":
1757
+ response_mode = "true_false"
1758
+ elif item_type in {"numeric_input", "number_line"}:
1759
+ response_mode = "numeric_response"
1760
+ elif item_type in {"fill_blank", "short_answer", "oral_response", "graph_interaction"}:
1761
+ response_mode = "text_response"
1762
+ elif item_type == "ordering":
1763
+ response_mode = "ordering"
1764
+ elif item_type in {"matching", "drag_drop"}:
1765
+ response_mode = "drag_drop"
1766
+ elif item_type == "whiteboard_response":
1767
+ response_mode = "drawing_response"
1768
+ elif item_type == "interactive_game":
1769
+ response_mode = "drawing_response"
1770
+ else:
1771
+ response_mode = "text_response"
1772
+
1773
+ scoring_mode = {
1774
+ "single_choice": "exact",
1775
+ "multiple_choice": "unordered_set",
1776
+ "true_false": "exact",
1777
+ "numeric_response": "numeric",
1778
+ "text_response": "exact",
1779
+ "ordering": "ordered",
1780
+ "drag_drop": "mapping",
1781
+ "drawing_response": "external",
1782
+ }[response_mode]
1783
+
1784
+ if item_type in {"whiteboard_response", "interactive_game"}:
1785
+ scoring_mode = "external"
1786
+
1787
+ return {
1788
+ "task_id": str(q.get("question_id") or f"WB-{hashlib.sha1(prompt.encode('utf-8')).hexdigest()[:12]}"),
1789
+ "question_id": str(q.get("question_id") or ""),
1790
+ "skill_id": str(q.get("skill_id") or ""),
1791
+ "badge_id": str(q.get("badge_id") or ""),
1792
+ "source": str(source or "platform"),
1793
+ "instruction_ar": prompt,
1794
+ "response_mode": response_mode,
1795
+ "options": options,
1796
+ "drag_items": payload.get("drag_items", []),
1797
+ "drop_zones": payload.get("drop_zones", []),
1798
+ "correct_answer": q.get("correct_response", q.get("correct_answer")) if include_answer else None,
1799
+ "scoring": {
1800
+ "mode": scoring_mode,
1801
+ "tolerance": float(q.get("numeric_tolerance", 0) or 0),
1802
+ "incorrect_feedback_ar": "الإجابة غير صحيحة.",
1803
+ },
1804
+ "feedback_policy": {"show_immediate": bool(include_answer), "show_correct_answer": False},
1805
+ "allowed_tools": ["pen", "highlighter", "eraser", "select", "text"],
1806
+ "clear_page": True,
1807
+ "max_attempts": max(1, int(max_attempts or 1)),
1808
+ "stop_on_first_confirmed_error": bool(stop_on_first_confirmed_error),
1809
+ "metadata": {
1810
+ "item_type": item_type,
1811
+ "difficulty": q.get("difficulty"),
1812
+ "difficulty_source": q.get("difficulty_source"),
1813
+ "error_type_if_wrong": q.get("error_type_if_wrong", ""),
1814
+ "content_origin": q.get("content_origin", ""),
1815
+ "mastery_eligible": q.get("mastery_eligible", True),
1816
+ },
1817
+ }
1818
+
1819
+
1820
+ def _external_runtime_question(self, item: Dict[str, Any]) -> Dict[str, Any]:
1821
+ choices = list(item.get("choices") or [])
1822
+ correct = item.get("correct_response", item.get("correct_answer"))
1823
+ question_id = str(item.get("external_item_id") or item.get("question_id") or item.get("source_item_id") or "")
1824
+ result = {
1825
+ "success": True,
1826
+ "version": VERSION,
1827
+ "question_id": question_id,
1828
+ "item_id": question_id,
1829
+ "skill_id": str(item.get("skill_id") or ""),
1830
+ "template_id": str(item.get("template_id") or ""),
1831
+ "item_type": str(item.get("item_type") or "multiple_choice"),
1832
+ "renderer": str(item.get("renderer") or item.get("item_type") or "multiple_choice"),
1833
+ "scoring_mode": str(item.get("scoring_mode") or "exact_match"),
1834
+ "difficulty": _safe_int(item.get("difficulty"), 3),
1835
+ "difficulty_source": "external_item_validated",
1836
+ "cognitive_domain": str(item.get("cognitive_domain") or "applying"),
1837
+ "usage_contexts": list(item.get("usage_contexts") or ["diagnostic", "practice"]),
1838
+ "question": str(item.get("question") or ""),
1839
+ "prompt": str(item.get("question") or ""),
1840
+ "question_ar": str(item.get("question_ar") or item.get("question") or ""),
1841
+ "choices": choices,
1842
+ "correct_answer": correct,
1843
+ "answer": correct,
1844
+ "correct_response": correct,
1845
+ "numeric_tolerance": item.get("numeric_tolerance"),
1846
+ "partial_credit": item.get("partial_credit", False),
1847
+ "interaction_payload": dict(item.get("interaction_payload") or {}),
1848
+ "response_schema": dict(item.get("response_schema") or {}),
1849
+ "error_type_if_wrong": str(item.get("error_type_if_wrong") or "NOT_FULL_MASTERY"),
1850
+ "diagnostic_signal": str(item.get("diagnostic_signal") or ""),
1851
+ "allow_dont_know": True,
1852
+ "confirm_before_submit": True,
1853
+ "stop_on_first_confirmed_error": True,
1854
+ "mastery_eligible": bool(item.get("mastery_eligible", True)),
1855
+ "content_origin": "external_open_resource",
1856
+ "source_id": str(item.get("source_id") or ""),
1857
+ "source_item_id": str(item.get("source_item_id") or ""),
1858
+ "attribution": str(item.get("attribution") or ""),
1859
+ "license": str(item.get("license") or ""),
1860
+ "permission_status": str(item.get("permission_status") or ""),
1861
+ "commercial_use_allowed": item.get("commercial_use_allowed"),
1862
+ "language": str(item.get("language") or "ar"),
1863
+ "rights": {
1864
+ "origin": "external_open_resource",
1865
+ "external_item_text_copied": bool(item.get("adapted_or_verbatim") == "verbatim"),
1866
+ "production_use": "approved",
1867
+ "license": str(item.get("license") or ""),
1868
+ "permission_status": str(item.get("permission_status") or ""),
1869
+ "commercial_use_allowed": item.get("commercial_use_allowed"),
1870
+ "attribution": str(item.get("attribution") or ""),
1871
+ },
1872
+ }
1873
+ if choices and correct in choices:
1874
+ result["answer_index"] = choices.index(correct)
1875
+ result["validation"] = self.validate_question(result)
1876
+ if not result["validation"].get("success"):
1877
+ result["success"] = False
1878
+ return result
1879
+
1880
+ def get_questions_for_skill(
1881
+ self,
1882
+ skill_id: str,
1883
+ count: int = 5,
1884
+ grade: Optional[int] = None,
1885
+ difficulty: Optional[int] = None,
1886
+ seed: Any = None,
1887
+ usage_context: Optional[str] = None,
1888
+ exclude_question_ids: Optional[Iterable[str]] = None,
1889
+ exclude_question_texts: Optional[Iterable[str]] = None,
1890
+ prefer_external: bool = True,
1891
+ external_ratio: float = 0.35,
1892
+ allow_english_external: bool = False,
1893
+ commercial_mode: bool = True,
1894
+ **kwargs: Any,
1895
+ ) -> List[Dict[str, Any]]:
1896
+ count = max(1, min(int(count or 5), 100))
1897
+ excluded_ids = {str(x) for x in (exclude_question_ids or [])}
1898
+ excluded_texts = {str(x).strip() for x in (exclude_question_texts or [])}
1899
+ selected: List[Dict[str, Any]] = []
1900
+ if prefer_external:
1901
+ candidates = []
1902
+ for item in self.external_items_by_skill.get(str(skill_id), []):
1903
+ if commercial_mode and item.get("commercial_use_allowed") is False:
1904
+ continue
1905
+ language = str(item.get("language") or "ar").lower()
1906
+ if language != "ar" and not allow_english_external:
1907
+ continue
1908
+ if usage_context and usage_context not in (item.get("usage_contexts") or []):
1909
+ continue
1910
+ if difficulty is not None and abs(_safe_int(item.get("difficulty"), 3) - int(difficulty)) > 1:
1911
+ continue
1912
+ qid = str(item.get("external_item_id") or item.get("question_id") or item.get("source_item_id") or "")
1913
+ qtext = str(item.get("question") or "").strip()
1914
+ if not qid or not qtext or qid in excluded_ids or qtext in excluded_texts:
1915
+ continue
1916
+ candidates.append(item)
1917
+ rng = random.Random(str(seed) + "|external|" + str(skill_id))
1918
+ rng.shuffle(candidates)
1919
+ external_target = min(len(candidates), max(0, round(count * max(0.0, min(float(external_ratio), 1.0)))))
1920
+ for item in candidates[:external_target]:
1921
+ q = self._external_runtime_question(item)
1922
+ if q.get("success"):
1923
+ selected.append(q)
1924
+ generated_needed = count - len(selected)
1925
+ if generated_needed > 0:
1926
+ generated = super().get_questions_for_skill(
1927
+ skill_id=skill_id,
1928
+ count=generated_needed,
1929
+ grade=grade,
1930
+ difficulty=difficulty,
1931
+ seed=seed,
1932
+ usage_context=usage_context,
1933
+ exclude_question_ids=excluded_ids | {q.get("question_id", "") for q in selected},
1934
+ exclude_question_texts=excluded_texts | {q.get("question", "") for q in selected},
1935
+ **kwargs,
1936
+ )
1937
+ selected.extend(generated)
1938
+ return selected[:count]
1939
+
1940
+
1941
+ @staticmethod
1942
+ def question_fingerprint(question: Dict[str, Any]) -> str:
1943
+ """بصمة مستقرة تمنع تكرار نص السؤال حتى لو تغير question_id."""
1944
+ skill_id = str(question.get("skill_id") or "").strip()
1945
+ text = re.sub(
1946
+ r"\s+",
1947
+ " ",
1948
+ str(question.get("question") or question.get("prompt") or "").strip().lower(),
1949
+ )
1950
+ return hashlib.sha256(f"{skill_id}|{text}".encode("utf-8")).hexdigest()[:24]
1951
+
1952
+ def get_challenge_questions(
1953
+ self,
1954
+ skill_ids: Optional[Sequence[str]] = None,
1955
+ badge_id: str = "",
1956
+ count: Optional[int] = None,
1957
+ grade: Optional[int] = None,
1958
+ seed: Any = None,
1959
+ exclude_question_ids: Optional[Iterable[str]] = None,
1960
+ exclude_question_texts: Optional[Iterable[str]] = None,
1961
+ exclude_fingerprints: Optional[Iterable[str]] = None,
1962
+ external_ratio: Optional[float] = None,
1963
+ **kwargs: Any,
1964
+ ) -> List[Dict[str, Any]]:
1965
+ """
1966
+ يبني اختبار تحدٍّ متوازنًا عبر المهارات والصعوبة، بلا تلميحات،
1967
+ ويولد بدائل جديدة قبل السماح بتكرار سؤال سابق.
1968
+ """
1969
+ policy = dict(getattr(self, "challenge_policy", {}) or {})
1970
+ target_count = int(
1971
+ count
1972
+ or policy.get("default_question_count", 20)
1973
+ or 20
1974
+ )
1975
+ target_count = max(
1976
+ int(policy.get("minimum_question_count", 12) or 12),
1977
+ min(
1978
+ target_count,
1979
+ int(policy.get("maximum_question_count", 40) or 40),
1980
+ ),
1981
+ )
1982
+
1983
+ resolved_skills: List[str] = []
1984
+ for skill_id in list(skill_ids or []):
1985
+ key = str(skill_id or "").strip()
1986
+ if key in self.skills and key not in resolved_skills:
1987
+ resolved_skills.append(key)
1988
+
1989
+ if not resolved_skills and badge_id:
1990
+ for skill_id in self.skills_by_badge.get(str(badge_id), []):
1991
+ if skill_id in self.skills and skill_id not in resolved_skills:
1992
+ resolved_skills.append(skill_id)
1993
+
1994
+ if grade is not None:
1995
+ filtered = []
1996
+ for skill_id in resolved_skills:
1997
+ grade_range = self.skills[skill_id].get("grade_range") or {}
1998
+ minimum = _safe_int(grade_range.get("min"), 0)
1999
+ maximum = _safe_int(grade_range.get("max"), 12)
2000
+ if minimum <= int(grade) <= maximum:
2001
+ filtered.append(skill_id)
2002
+ resolved_skills = filtered or resolved_skills
2003
+
2004
+ if not resolved_skills:
2005
+ return []
2006
+
2007
+ excluded_ids = {str(item) for item in (exclude_question_ids or [])}
2008
+ excluded_texts = {
2009
+ re.sub(r"\s+", " ", str(item).strip())
2010
+ for item in (exclude_question_texts or [])
2011
+ }
2012
+ excluded_fps = {str(item) for item in (exclude_fingerprints or [])}
2013
+
2014
+ difficulty_distribution = policy.get("difficulty_distribution") or {
2015
+ "3": 0.20,
2016
+ "4": 0.45,
2017
+ "5": 0.35,
2018
+ }
2019
+ difficulty_sequence: List[int] = []
2020
+ for value, ratio in difficulty_distribution.items():
2021
+ number = max(1, round(target_count * float(ratio)))
2022
+ difficulty_sequence.extend([int(value)] * number)
2023
+ while len(difficulty_sequence) < target_count:
2024
+ difficulty_sequence.append(5)
2025
+ difficulty_sequence = difficulty_sequence[:target_count]
2026
+
2027
+ rng = random.Random(f"{seed}|challenge|{badge_id}|{'|'.join(resolved_skills)}")
2028
+ rng.shuffle(difficulty_sequence)
2029
+
2030
+ ratio = (
2031
+ float(external_ratio)
2032
+ if external_ratio is not None
2033
+ else float(
2034
+ (policy.get("pool_rules") or {}).get(
2035
+ "external_ratio_when_available", 0.25
2036
+ )
2037
+ )
2038
+ )
2039
+
2040
+ selected: List[Dict[str, Any]] = []
2041
+ selected_fps: set[str] = set()
2042
+ attempts = 0
2043
+ max_attempts = target_count * max(50, len(resolved_skills) * 12)
2044
+
2045
+ while len(selected) < target_count and attempts < max_attempts:
2046
+ skill_id = resolved_skills[attempts % len(resolved_skills)]
2047
+ difficulty = difficulty_sequence[len(selected) % len(difficulty_sequence)]
2048
+ variant_seed = f"{seed}|challenge|{attempts}|{skill_id}|{difficulty}"
2049
+
2050
+ challenge_templates = [
2051
+ template
2052
+ for template in self.templates_by_skill.get(skill_id, [])
2053
+ if "challenge" in (template.get("usage_contexts") or [])
2054
+ and bool(template.get("mastery_eligible", True))
2055
+ and str(template.get("scoring_mode") or "") not in {
2056
+ "whiteboard_evidence",
2057
+ "tool_state_match",
2058
+ "rubric",
2059
+ "external",
2060
+ }
2061
+ ]
2062
+ challenge_templates.sort(
2063
+ key=lambda template: (
2064
+ abs(_safe_int(template.get("difficulty"), 3) - difficulty),
2065
+ str(template.get("challenge_role") or template.get("template_id") or ""),
2066
+ )
2067
+ )
2068
+ if not challenge_templates:
2069
+ attempts += 1
2070
+ continue
2071
+
2072
+ # ندور بين القوالب المتقاربة في الصعوبة بدل اختيار القالب الأول دائمًا.
2073
+ close_templates = [
2074
+ template
2075
+ for template in challenge_templates
2076
+ if abs(_safe_int(template.get("difficulty"), 3) - difficulty) <= 1
2077
+ ] or challenge_templates
2078
+ template = close_templates[attempts % len(close_templates)]
2079
+ question = self.generate_question(
2080
+ skill_id=skill_id,
2081
+ template=template,
2082
+ seed=variant_seed,
2083
+ sequence=attempts,
2084
+ )
2085
+ attempts += 1
2086
+ if not question.get("success"):
2087
+ continue
2088
+ question = dict(question)
2089
+ if not bool(question.get("mastery_eligible", True)):
2090
+ continue
2091
+ if str(question.get("scoring_mode")) in {
2092
+ "whiteboard_evidence",
2093
+ "tool_state_match",
2094
+ "rubric",
2095
+ "external",
2096
+ }:
2097
+ continue
2098
+
2099
+ fingerprint = self.question_fingerprint(question)
2100
+ if fingerprint in excluded_fps or fingerprint in selected_fps:
2101
+ continue
2102
+
2103
+ question["question_fingerprint"] = fingerprint
2104
+ question["question_kind"] = "challenge"
2105
+ question["challenge_test"] = True
2106
+ question["challenge_index"] = len(selected) + 1
2107
+ question["allow_dont_know"] = False
2108
+ question["allow_hints"] = False
2109
+ question["stop_on_first_confirmed_error"] = False
2110
+ selected.append(question)
2111
+ selected_fps.add(fingerprint)
2112
+
2113
+ return selected
2114
+
2115
+ def get_challenge_test_questions(
2116
+ self,
2117
+ skill_ids: Optional[Sequence[str]] = None,
2118
+ badge_id: str = "",
2119
+ count: Optional[int] = None,
2120
+ **kwargs: Any,
2121
+ ) -> List[Dict[str, Any]]:
2122
+ return self.get_challenge_questions(
2123
+ skill_ids=skill_ids,
2124
+ badge_id=badge_id,
2125
+ count=count,
2126
+ **kwargs,
2127
+ )
2128
+
2129
+ def question_bank_capacity(self) -> Dict[str, Any]:
2130
+ challenge_templates = sum(
2131
+ 1
2132
+ for templates in self.templates_by_skill.values()
2133
+ for template in templates
2134
+ if "challenge" in (template.get("usage_contexts") or [])
2135
+ )
2136
+ return {
2137
+ "skills_count": len(self.skills),
2138
+ "templates_total": sum(
2139
+ len(templates) for templates in self.templates_by_skill.values()
2140
+ ),
2141
+ "templates_per_skill_min": min(
2142
+ (len(templates) for templates in self.templates_by_skill.values()),
2143
+ default=0,
2144
+ ),
2145
+ "challenge_templates_total": challenge_templates,
2146
+ "challenge_templates_per_skill": (
2147
+ challenge_templates // len(self.skills)
2148
+ if self.skills
2149
+ else 0
2150
+ ),
2151
+ "external_items_total": sum(
2152
+ len(items) for items in self.external_items_by_skill.values()
2153
+ ),
2154
+ "external_skills_covered": len(self.external_items_by_skill),
2155
+ "seeded_generation": True,
2156
+ "anti_repetition_supported": True,
2157
+ "closed_for_production": True,
2158
+ }
2159
+
2160
+ def summary(self) -> Dict[str, Any]:
2161
+ base = super().summary()
2162
+ type_counts: Dict[str, int] = {}
2163
+ difficulty_counts: Dict[str, int] = {}
2164
+ for templates in self.templates_by_skill.values():
2165
+ for template in templates:
2166
+ item_type = str(template.get("item_type") or "multiple_choice")
2167
+ type_counts[item_type] = type_counts.get(item_type, 0) + 1
2168
+ difficulty = str(template.get("difficulty", ""))
2169
+ difficulty_counts[difficulty] = difficulty_counts.get(difficulty, 0) + 1
2170
+ base.update({
2171
+ "version": VERSION,
2172
+ "skills_with_templates": len(self.templates_by_skill),
2173
+ "skills_without_templates": self.bank_coverage.get("skills_without_templates", []),
2174
+ "question_types_registered": len(self.question_type_records),
2175
+ "question_type_counts": type_counts,
2176
+ "difficulty_counts": difficulty_counts,
2177
+ "source_registry_loaded": bool(self.assessment_source_registry.get("sources")),
2178
+ "assessment_sources_count": len(self.assessment_source_registry.get("sources", [])),
2179
+ "external_items_approved_count": sum(len(v) for v in self.external_items_by_skill.values()),
2180
+ "external_skills_covered_count": len(self.external_items_by_skill),
2181
+ "external_import_pipeline_ready": True,
2182
+ "challenge_policy_loaded": bool(getattr(self, "challenge_policy", {})),
2183
+ "math_bank_capacity": self.question_bank_capacity(),
2184
+ "complete_k12_question_bank": (
2185
+ len(self.templates_by_skill) == len(self.skills)
2186
+ and not self.bank_coverage.get("skills_without_templates")
2187
+ ),
2188
+ "concept_dictionary_loaded": bool(self.clarification_manager),
2189
+ "concept_dictionary_error": self.clarification_error,
2190
+ "clarification_scored": False,
2191
+ "clarification_is_hint": False,
2192
+ "message": "بنك الأسئلة K–12 مكتمل بالقوالب متعددة الأنواع، والصعوبة مأخوذة من مخطط المهارة ثم يختارها المدير.",
2193
+ })
2194
+ return base
2195
+
2196
+
2197
+ _MANAGER: Optional[ComprehensiveQuestionBankManager] = None
2198
  _LOCK=threading.Lock()
2199
 
2200
+ def get_manager() -> ComprehensiveQuestionBankManager:
2201
  global _MANAGER
2202
  with _LOCK:
2203
+ if _MANAGER is None: _MANAGER=ComprehensiveQuestionBankManager()
2204
  return _MANAGER
2205
 
2206
  def get_badge_diagnostic_questions(subject="math",grade=None,badge_id="",count=5,difficulty=None,skill_id="",**kwargs):
 
2215
  def get_mastery_units(grade=None, learning_mode="gap_recovery"):
2216
  return get_manager().get_mastery_units(grade=grade, learning_mode=learning_mode)
2217
 
2218
+
2219
+ def get_challenge_questions(skill_ids=None,badge_id="",count=None,**kwargs):
2220
+ return get_manager().get_challenge_questions(
2221
+ skill_ids=skill_ids,
2222
+ badge_id=badge_id,
2223
+ count=count,
2224
+ **kwargs,
2225
+ )
2226
+
2227
+ def get_challenge_test_questions(skill_ids=None,badge_id="",count=None,**kwargs):
2228
+ return get_manager().get_challenge_test_questions(
2229
+ skill_ids=skill_ids,
2230
+ badge_id=badge_id,
2231
+ count=count,
2232
+ **kwargs,
2233
+ )
2234
+
2235
+ def question_bank_capacity():
2236
+ return get_manager().question_bank_capacity()
2237
+
2238
+
2239
  def get_questions_for_badge(badge_id,count=5,**kwargs):
2240
  return get_manager().get_questions_for_badge(badge_id,count=count,**kwargs)
2241
 
 
2256
  def get_whiteboard_task_for_skill(skill_id, **kwargs):
2257
  return get_manager().get_whiteboard_task_for_skill(skill_id, **kwargs)
2258
 
2259
+ def clarify_question(question, **kwargs):
2260
+ return get_manager().clarify_question(question, **kwargs)
2261
+
2262
+ def get_concept_dictionary_entry(concept_id):
2263
+ return get_manager().get_concept_dictionary_entry(concept_id)
2264
+
2265
+ def get_symbol_dictionary_entry(symbol_id):
2266
+ return get_manager().get_symbol_dictionary_entry(symbol_id)
2267
+
2268
+ def get_remedial_concept_package(skill_id):
2269
+ return get_manager().get_remedial_concept_package(skill_id)
2270
+
2271
+ def score_response(question, response):
2272
+ return get_manager().score_response(question, response)
2273
+
2274
  def summary():
2275
  return get_manager().summary()
question_clarification_manager.py ADDED
@@ -0,0 +1,439 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # question_clarification_manager.py
2
+ #!/usr/bin/env python3
3
+ # -*- coding: utf-8 -*-
4
+
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+ import json
9
+ import re
10
+ import threading
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any, Dict, Iterable, List, Optional
14
+
15
+
16
+ VERSION = "question_clarification_manager_v1_math_concepts_symbols"
17
+
18
+
19
+ def _read_json(path: Path) -> Dict[str, Any]:
20
+ data = json.loads(path.read_text(encoding="utf-8"))
21
+ if not isinstance(data, dict):
22
+ raise ValueError(f"جذر الملف يجب أن يكون كائنًا: {path.name}")
23
+ return data
24
+
25
+
26
+ def _normalize_text(value: Any) -> str:
27
+ text = str(value or "").strip()
28
+ text = re.sub(r"[\u064B-\u065F\u0670]", "", text)
29
+ text = text.replace("ـ", "")
30
+ return re.sub(r"\s+", " ", text)
31
+
32
+
33
+ class QuestionClarificationManager:
34
+ """
35
+ طبقة عدالة لغوية مستقلة عن التلميحات.
36
+
37
+ - تعيد صياغة فعل المطلوب دون شرح طريقة الحل.
38
+ - تشرح الرموز الموجودة في السؤال.
39
+ - تعرض أسماء المفهوم البديلة.
40
+ - تمنع عرض تعريف المفهوم إذا كان التعريف نفسه هو موضع التقييم.
41
+ - لا تغيّر العلامة ولا عدد المحاولات.
42
+ """
43
+
44
+ COMMAND_RULES = [
45
+ (r"^\s*أوجد", "المطلوب: احسب أو حدّد القيمة المطلوبة."),
46
+ (r"^\s*احسب", "المطلوب: نفّذ العملية واكتب الناتج النهائي."),
47
+ (r"^\s*قارن", "المطلوب: حدّد أي القيمتين أكبر أو أصغر، أو هل هما متساويتان."),
48
+ (r"^\s*رت[ّ]?ب", "المطلوب: ضع القيم حسب الترتيب المذكور في السؤال."),
49
+ (r"^\s*قر[ّ]?ب", "المطلوب: اكتب قيمة تقريبية حسب المنزلة المحددة."),
50
+ (r"^\s*حل(?:\s+المعادلة)?", "المطلوب: أوجد قيمة المتغير التي تجعل العبارة صحيحة."),
51
+ (r"^\s*بس[ّ]?ط", "المطلوب: اكتب التعبير في صورة أبسط مع بقاء القيمة نفسها."),
52
+ (r"^\s*حل[ّ]?ل", "المطلوب: فكّك العدد أو التعبير إلى عوامله أو أجزائه المطلوبة."),
53
+ (r"^\s*مث[ّ]?ل", "المطلوب: اعرض الفكرة بالرسم أو الجدول أو الرمز المطلوب."),
54
+ (r"^\s*فس[ّ]?ر", "المطلوب: اكتب سببًا رياضيًا يوضح لماذا النتيجة صحيحة."),
55
+ (r"^\s*برهن", "المطلوب: اكتب خطوات منطقية تثبت العبارة دون الاكتفاء بالجواب."),
56
+ (r"^\s*استنتج", "المطلوب: استخدم المعطيات للوصول إلى نتيجة جديدة."),
57
+ (r"^\s*حد[ّ]?د", "المطلوب: اختر أو اكتب العنصر الذي يحقق الشرط."),
58
+ (r"^\s*اختر", "المطلوب: اختر البديل الذي يحقق شروط السؤال."),
59
+ (r"^\s*قد[ّ]?ر", "المطلوب: أوجد قيمة قريبة تساعد على التحقق، لا قيمة دقيقة بالضرورة."),
60
+ ]
61
+
62
+ REPHRASE_RULES = [
63
+ (r"\bأوجد\b", "احسب أو حدّد"),
64
+ (r"\bقارن\b", "بيّن العلاقة"),
65
+ (r"\bرت[ّ]?ب\b", "ضع بالترتيب"),
66
+ (r"\bقر[ّ]?ب\b", "اكتب القيمة التقريبية لـ"),
67
+ (r"\bبس[ّ]?ط\b", "اكتب في صورة أبسط"),
68
+ (r"\bفس[ّ]?ر\b", "اشرح السبب"),
69
+ (r"\bبرهن\b", "أثبت بخطوات منطقية"),
70
+ (r"\bحد[ّ]?د\b", "عيّن"),
71
+ ]
72
+
73
+ CHARACTER_SYMBOLS = {
74
+ "+": "SYM_PLUS",
75
+ "-": "SYM_MINUS",
76
+ "−": "SYM_MINUS",
77
+ "±": "SYM_PLUS_MINUS",
78
+ "×": "SYM_MULTIPLY_CROSS",
79
+ "·": "SYM_MULTIPLY_DOT",
80
+ "÷": "SYM_DIVIDE",
81
+ "=": "SYM_EQUALS",
82
+ "≠": "SYM_NOT_EQUALS",
83
+ "<": "SYM_LESS_THAN",
84
+ ">": "SYM_GREATER_THAN",
85
+ "≤": "SYM_LESS_EQUAL",
86
+ "≥": "SYM_GREATER_EQUAL",
87
+ "≈": "SYM_APPROX",
88
+ "≡": "SYM_EQUIVALENT",
89
+ "%": "SYM_PERCENT",
90
+ "‰": "SYM_PER_MILLE",
91
+ "∝": "SYM_PROPORTIONAL",
92
+ "√": "SYM_SQUARE_ROOT",
93
+ "π": "SYM_PI",
94
+ "∞": "SYM_INFINITY",
95
+ "Σ": "SYM_SUMMATION",
96
+ "Π": "SYM_PRODUCT",
97
+ "∅": "SYM_EMPTY_SET",
98
+ "φ": "SYM_PHI",
99
+ "ϕ": "SYM_PHI",
100
+ "⌀": "SYM_DIAMETER",
101
+ "∈": "SYM_ELEMENT_OF",
102
+ "∉": "SYM_NOT_ELEMENT_OF",
103
+ "⊂": "SYM_SUBSET",
104
+ "⊆": "SYM_SUBSET_EQUAL",
105
+ "⊃": "SYM_SUPERSET",
106
+ "∪": "SYM_UNION",
107
+ "∩": "SYM_INTERSECTION",
108
+ "∀": "SYM_FOR_ALL",
109
+ "∃": "SYM_EXISTS",
110
+ "¬": "SYM_NOT",
111
+ "∧": "SYM_AND",
112
+ "∨": "SYM_OR",
113
+ "⇒": "SYM_IMPLIES",
114
+ "⇔": "SYM_IFF",
115
+ "∴": "SYM_THEREFORE",
116
+ "ℕ": "SYM_NATURAL_NUMBERS",
117
+ "ℤ": "SYM_INTEGERS",
118
+ "ℚ": "SYM_RATIONALS",
119
+ "ℝ": "SYM_REALS",
120
+ "ℂ": "SYM_COMPLEXES",
121
+ "∠": "SYM_ANGLE",
122
+ "°": "SYM_DEGREE",
123
+ "⟂": "SYM_PERPENDICULAR",
124
+ "∥": "SYM_PARALLEL",
125
+ "≅": "SYM_CONGRUENT",
126
+ "∼": "SYM_SIMILAR",
127
+ "△": "SYM_TRIANGLE",
128
+ "Δ": "SYM_DELTA",
129
+ "↦": "SYM_MAPSTO",
130
+ "→": "SYM_ARROW",
131
+ "∘": "SYM_COMPOSITION",
132
+ "∫": "SYM_INTEGRAL",
133
+ "∂": "SYM_PARTIAL",
134
+ "∇": "SYM_NABLA",
135
+ }
136
+
137
+ def __init__(self, root_dir: Optional[str | Path] = None) -> None:
138
+ self.version = VERSION
139
+ self.root_dir = Path(root_dir or Path(__file__).resolve().parent)
140
+ self.concepts_path = self.root_dir / "math_concepts_global.json"
141
+ self.symbols_path = self.root_dir / "math_symbols_global.json"
142
+ self.skills_path = self.root_dir / "math_skills_global.json"
143
+ self._lock = threading.RLock()
144
+ self.reload()
145
+
146
+ def reload(self) -> Dict[str, Any]:
147
+ with self._lock:
148
+ self.concepts_data = _read_json(self.concepts_path)
149
+ self.symbols_data = _read_json(self.symbols_path)
150
+ self.skills_data = _read_json(self.skills_path)
151
+ self.concepts = {
152
+ str(item["concept_id"]): item
153
+ for item in self.concepts_data.get("concepts", [])
154
+ }
155
+ self.symbols = {
156
+ str(item["symbol_id"]): item
157
+ for item in self.symbols_data.get("symbols", [])
158
+ }
159
+ self.skills = {
160
+ str(item["skill_id"]): item
161
+ for item in self.skills_data.get("skills", [])
162
+ }
163
+ self.concept_ids_by_skill: Dict[str, List[str]] = {}
164
+ for skill_id, skill in self.skills.items():
165
+ self.concept_ids_by_skill[skill_id] = [
166
+ str(value) for value in (skill.get("concept_ids") or [])
167
+ if str(value) in self.concepts
168
+ ]
169
+ return self.summary()
170
+
171
+ def summary(self) -> Dict[str, Any]:
172
+ return {
173
+ "version": self.version,
174
+ "concepts_count": len(self.concepts),
175
+ "symbols_count": len(self.symbols),
176
+ "skills_count": len(self.skills),
177
+ "clarification_scored": False,
178
+ "clarification_is_hint": False,
179
+ }
180
+
181
+ def get_symbol(self, symbol_id: str) -> Dict[str, Any]:
182
+ item = self.symbols.get(str(symbol_id))
183
+ return dict(item) if item else {}
184
+
185
+ def get_concept(self, concept_id: str) -> Dict[str, Any]:
186
+ item = self.concepts.get(str(concept_id))
187
+ return dict(item) if item else {}
188
+
189
+ def get_concepts_for_skill(self, skill_id: str) -> List[Dict[str, Any]]:
190
+ return [
191
+ dict(self.concepts[concept_id])
192
+ for concept_id in self.concept_ids_by_skill.get(str(skill_id), [])
193
+ if concept_id in self.concepts
194
+ ]
195
+
196
+ def detect_symbol_ids(
197
+ self,
198
+ question_text: str,
199
+ skill_id: str = "",
200
+ ) -> List[str]:
201
+ text = str(question_text or "")
202
+ found: List[str] = []
203
+
204
+ for character, symbol_id in self.CHARACTER_SYMBOLS.items():
205
+ if character in text and symbol_id in self.symbols and symbol_id not in found:
206
+ found.append(symbol_id)
207
+
208
+ # أنماط نصية لا تعتمد على محرف واحد.
209
+ patterns = [
210
+ (r"\bf\s*\(", "SYM_FUNCTION"),
211
+ (r"\b(?:log)\b", "SYM_LOG"),
212
+ (r"\b(?:ln)\b", "SYM_LN"),
213
+ (r"\blim\b", "SYM_LIMIT"),
214
+ (r"\bdy\s*/\s*dx\b", "SYM_DERIVATIVE_LEIBNIZ"),
215
+ (r"\bf\s*[′']", "SYM_DERIVATIVE_PRIME"),
216
+ (r"\bP\s*\([^)]*\|[^)]*\)", "SYM_CONDITIONAL_PROBABILITY"),
217
+ (r"\bP\s*\(", "SYM_PROBABILITY"),
218
+ (r"\bx̄\b", "SYM_MEAN"),
219
+ (r"\bdet\s*\(", "SYM_DETERMINANT"),
220
+ ]
221
+ for pattern, symbol_id in patterns:
222
+ if re.search(pattern, text, flags=re.IGNORECASE) and symbol_id in self.symbols:
223
+ if symbol_id not in found:
224
+ found.append(symbol_id)
225
+
226
+ skill = self.skills.get(str(skill_id), {})
227
+ for symbol_id in skill.get("symbol_ids") or []:
228
+ if symbol_id in self.symbols and symbol_id not in found:
229
+ found.append(symbol_id)
230
+
231
+ return found
232
+
233
+ def get_symbol_cards(
234
+ self,
235
+ question_text: str,
236
+ skill_id: str = "",
237
+ assessed_symbol_ids: Optional[Iterable[str]] = None,
238
+ ) -> List[Dict[str, Any]]:
239
+ assessed = {str(value) for value in (assessed_symbol_ids or [])}
240
+ cards: List[Dict[str, Any]] = []
241
+ for symbol_id in self.detect_symbol_ids(question_text, skill_id):
242
+ item = self.symbols.get(symbol_id)
243
+ if not item:
244
+ continue
245
+ card = {
246
+ "symbol_id": symbol_id,
247
+ "display": item.get("display"),
248
+ "name_ar": item.get("name_ar"),
249
+ "spoken_ar": item.get("spoken_ar"),
250
+ "clarification_scored": False,
251
+ "clarification_is_hint": False,
252
+ }
253
+ if symbol_id in assessed:
254
+ card["definition_hidden"] = True
255
+ card["message_ar"] = "معنى هذا الرمز هو جزء من المطلوب في السؤال، لذلك لا يظهر التعريف الآن."
256
+ else:
257
+ card["definition_hidden"] = False
258
+ card["student_definition_ar"] = item.get("student_definition_ar")
259
+ card["examples"] = item.get("examples") or []
260
+ card["common_confusions"] = item.get("common_confusions") or []
261
+ cards.append(card)
262
+ return cards
263
+
264
+ def get_concept_cards(
265
+ self,
266
+ skill_id: str,
267
+ assessed_concept_ids: Optional[Iterable[str]] = None,
268
+ assessment_mode: bool = True,
269
+ ) -> List[Dict[str, Any]]:
270
+ assessed = {str(value) for value in (assessed_concept_ids or [])}
271
+ cards: List[Dict[str, Any]] = []
272
+ for concept in self.get_concepts_for_skill(skill_id):
273
+ concept_id = str(concept.get("concept_id"))
274
+ card = {
275
+ "concept_id": concept_id,
276
+ "canonical_name_ar": concept.get("canonical_name_ar"),
277
+ "aliases_ar": concept.get("aliases_ar") or [],
278
+ "symbol_ids": concept.get("symbol_ids") or [],
279
+ "clarification_scored": False,
280
+ "clarification_is_hint": False,
281
+ }
282
+ hide = (
283
+ assessment_mode
284
+ and concept_id in assessed
285
+ and bool(
286
+ (concept.get("clarification_policy") or {}).get(
287
+ "hide_full_definition_when_concept_itself_is_assessed",
288
+ True,
289
+ )
290
+ )
291
+ )
292
+ if hide:
293
+ card["definition_hidden"] = True
294
+ card["message_ar"] = "التعريف الكامل مخفي لأن المفهوم نفسه هو موضع التقييم."
295
+ else:
296
+ card["definition_hidden"] = False
297
+ card["definition_ar"] = concept.get("definition_ar")
298
+ card["student_friendly_explanation_ar"] = concept.get(
299
+ "student_friendly_explanation_ar"
300
+ )
301
+ cards.append(card)
302
+ return cards
303
+
304
+ def instruction_clarification(self, question_text: str) -> str:
305
+ text = _normalize_text(question_text)
306
+ for pattern, explanation in self.COMMAND_RULES:
307
+ if re.search(pattern, text):
308
+ return explanation
309
+ return "المطلوب: اقرأ المعطيات وحدد ما الذي يجب كتابته أو اختياره، دون تغيير طريقة الحل."
310
+
311
+ def alternative_wording(self, question_text: str) -> str:
312
+ text = _normalize_text(question_text)
313
+ alternative = text
314
+ for pattern, replacement in self.REPHRASE_RULES:
315
+ if re.search(pattern, alternative):
316
+ alternative = re.sub(pattern, replacement, alternative, count=1)
317
+ break
318
+ return alternative
319
+
320
+ def clarify_question(
321
+ self,
322
+ question_text: str,
323
+ skill_id: str = "",
324
+ assessed_concept_ids: Optional[Iterable[str]] = None,
325
+ assessed_symbol_ids: Optional[Iterable[str]] = None,
326
+ assessment_mode: bool = True,
327
+ ) -> Dict[str, Any]:
328
+ original = _normalize_text(question_text)
329
+ alternative = self.alternative_wording(original)
330
+ event_id = hashlib.sha256(
331
+ f"{skill_id}|{original}|{datetime.now(timezone.utc).isoformat()}".encode("utf-8")
332
+ ).hexdigest()[:20]
333
+ return {
334
+ "success": True,
335
+ "clarification_event_id": f"CLARIFY-{event_id}",
336
+ "original_question_ar": original,
337
+ "alternative_wording_ar": alternative,
338
+ "instruction_clarification_ar": self.instruction_clarification(original),
339
+ "concept_cards": self.get_concept_cards(
340
+ skill_id=skill_id,
341
+ assessed_concept_ids=assessed_concept_ids,
342
+ assessment_mode=assessment_mode,
343
+ ),
344
+ "symbol_cards": self.get_symbol_cards(
345
+ question_text=original,
346
+ skill_id=skill_id,
347
+ assessed_symbol_ids=assessed_symbol_ids,
348
+ ),
349
+ "evaluation_effect": {
350
+ "scored": False,
351
+ "counts_as_hint": False,
352
+ "reduces_mark": False,
353
+ "adds_attempt": False,
354
+ },
355
+ "safety_rules": {
356
+ "does_not_show_correct_answer": True,
357
+ "does_not_remove_options": True,
358
+ "does_not_name_required_operation_when_that_is_assessed": True,
359
+ },
360
+ }
361
+
362
+ def get_remedial_concept_package(self, skill_id: str) -> Dict[str, Any]:
363
+ concepts = self.get_concepts_for_skill(skill_id)
364
+ symbol_ids: List[str] = []
365
+ for concept in concepts:
366
+ for symbol_id in concept.get("symbol_ids") or []:
367
+ if symbol_id not in symbol_ids:
368
+ symbol_ids.append(symbol_id)
369
+ return {
370
+ "success": bool(concepts),
371
+ "skill_id": skill_id,
372
+ "concepts": concepts,
373
+ "symbols": [
374
+ dict(self.symbols[symbol_id])
375
+ for symbol_id in symbol_ids
376
+ if symbol_id in self.symbols
377
+ ],
378
+ "lesson_requirements": {
379
+ "must_explain_concept_names": True,
380
+ "must_explain_symbols_before_use": True,
381
+ "text_explanation_required": True,
382
+ "audio_explanation_supported": True,
383
+ "micro_video_supported": True,
384
+ "final_concept_check_required": True,
385
+ },
386
+ }
387
+
388
+ def build_clarification_usage_event(
389
+ self,
390
+ student_id: str,
391
+ question_id: str,
392
+ skill_id: str,
393
+ clarification_type: str,
394
+ ) -> Dict[str, Any]:
395
+ return {
396
+ "event_type": "question_clarification_used",
397
+ "student_id": str(student_id or ""),
398
+ "question_id": str(question_id or ""),
399
+ "skill_id": str(skill_id or ""),
400
+ "clarification_type": str(clarification_type or "question_rephrase"),
401
+ "occurred_at": datetime.now(timezone.utc).isoformat(),
402
+ "evaluation_effect": "none",
403
+ "quality_signal_only": True,
404
+ }
405
+
406
+
407
+ _MANAGER: Optional[QuestionClarificationManager] = None
408
+ _MANAGER_LOCK = threading.RLock()
409
+
410
+
411
+ def get_manager(root_dir: Optional[str | Path] = None) -> QuestionClarificationManager:
412
+ global _MANAGER
413
+ with _MANAGER_LOCK:
414
+ if _MANAGER is None or (
415
+ root_dir is not None
416
+ and Path(root_dir).resolve() != _MANAGER.root_dir.resolve()
417
+ ):
418
+ _MANAGER = QuestionClarificationManager(root_dir=root_dir)
419
+ return _MANAGER
420
+
421
+
422
+ def clarify_question(question_text: str, skill_id: str = "", **kwargs: Any) -> Dict[str, Any]:
423
+ return get_manager().clarify_question(
424
+ question_text=question_text,
425
+ skill_id=skill_id,
426
+ **kwargs,
427
+ )
428
+
429
+
430
+ def get_remedial_concept_package(skill_id: str) -> Dict[str, Any]:
431
+ return get_manager().get_remedial_concept_package(skill_id)
432
+
433
+
434
+ def get_symbol(symbol_id: str) -> Dict[str, Any]:
435
+ return get_manager().get_symbol(symbol_id)
436
+
437
+
438
+ def get_concept(concept_id: str) -> Dict[str, Any]:
439
+ return get_manager().get_concept(concept_id)
strategy_manager.py CHANGED
@@ -40,9 +40,14 @@ try:
40
  except Exception:
41
  SYSTEM_FOLDER = "system"
42
 
 
 
 
 
43
 
44
- STRATEGY_MANAGER_VERSION = "strategy_manager_v3_unified_evidence_prescriptive"
45
- STRATEGY_SCHEMA_VERSION = "strategy_schema_v3"
 
46
 
47
 
48
  class StrategyManager:
@@ -99,6 +104,10 @@ class StrategyManager:
99
  self.skills_manager_error = ""
100
  self._load_skills_manager()
101
 
 
 
 
 
102
  self._ensure_strategy_files()
103
 
104
  # =========================================================
@@ -180,6 +189,105 @@ class StrategyManager:
180
  digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:14]
181
  return f"strategy_{digest}"
182
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  # =========================================================
184
  # القراءة والكتابة والهجرة
185
  # =========================================================
@@ -810,6 +918,129 @@ class StrategyManager:
810
  # بناء عقد الاستراتيجية
811
  # =========================================================
812
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
813
  def choose_strategy(
814
  self,
815
  student: Optional[Dict[str, Any]],
@@ -857,9 +1088,16 @@ class StrategyManager:
857
  }
858
 
859
  skill_context = self.get_skill_context(skill_id)
 
860
  taxonomy_title = self._skill_title(skill_context, gap_skill or lesson_title)
861
  skill_title = taxonomy_title or gap_skill or lesson_title or "المهارة المستهدفة"
862
  profile = skill_context.get("diagnostic_profile", {})
 
 
 
 
 
 
863
 
864
  selected_error = str(error_type or lesson_context.get("error_type") or "").strip()
865
  if not selected_error and isinstance(profile, dict):
@@ -911,6 +1149,26 @@ class StrategyManager:
911
  media_requirements = {
912
  "required": True,
913
  "representations": representations,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
914
  "dynamic_visual_preferred": any(
915
  method in {"misconception_repair", "multiple_representations", "concrete_representational_abstract", "inquiry_science"}
916
  for method in methods
@@ -934,7 +1192,8 @@ class StrategyManager:
934
  "must_include": [
935
  "learning_objective", "teaching_method", "worked_example",
936
  "guided_practice", "checkpoint_or_closure", "failure_path",
937
- "media_requirements",
 
938
  ],
939
  "must_not_include": [
940
  "generic_long_lecture", "student_private_data", "model_names",
@@ -945,12 +1204,33 @@ class StrategyManager:
945
  teaching_strategy = {
946
  "methods": methods,
947
  "method_details": method_details,
948
- "teacher_tone": age_profile.get("teacher_tone", "ودود ومباشر"),
 
 
 
949
  "lesson_style": age_profile.get("lesson_style", "شرح ثم مثال ثم تدريب"),
950
- "max_teacher_sentence_words": age_profile.get("max_teacher_sentence_words", 18),
 
 
 
 
 
 
 
 
 
 
 
 
 
951
  "representations": representations,
952
  "feedback_rule": "فسّر سبب الخطأ وقدّم الخطوة التالية المناسبة دون وصف الطالب بالضعف.",
953
  "interaction_rule": "اعرض سؤالًا واحدًا في كل مرة وانتظر إجابة الطالب.",
 
 
 
 
 
954
  }
955
 
956
  remedial_strategy = {
@@ -981,6 +1261,14 @@ class StrategyManager:
981
  engagement_strategy = {
982
  "age_group": age_group,
983
  "age_profile": age_profile,
 
 
 
 
 
 
 
 
984
  "attention_policy": deepcopy(strategies.get("attention_policy", {})),
985
  "pomodoro": deepcopy(strategies.get("pomodoro", {})),
986
  }
@@ -1018,8 +1306,14 @@ class StrategyManager:
1018
  "remedial_strategy": remedial_strategy,
1019
  "assessment_strategy": assessment_strategy,
1020
  "media_strategy": media_requirements,
 
1021
  "engagement_strategy": engagement_strategy,
1022
  "taxonomy_context": skill_context,
 
 
 
 
 
1023
  }
1024
 
1025
  planner_instruction = self.build_planner_instruction(contract)
@@ -1093,6 +1387,7 @@ class StrategyManager:
1093
  remedial = contract.get("remedial_strategy", {})
1094
  assessment = contract.get("assessment_strategy", {})
1095
  media = contract.get("media_strategy", {})
 
1096
  return f"""
1097
  أنت تؤدي دور مخطط تعليمي داخل منصة Divid Teacher.
1098
  يجب أن تبني الخطة من عقد الاستراتيجية الآتي، ولا يجوز تجاهله أو استبداله بخطة عامة.
@@ -1106,6 +1401,17 @@ class StrategyManager:
1106
  فئة الخطأ: {remedial.get('error_category', '')}
1107
  المحاولة العلاجية: {remedial.get('route', '')}
1108
 
 
 
 
 
 
 
 
 
 
 
 
1109
  طرق التدريس الملزمة:
1110
  {self._format_list(teaching.get('methods', []))}
1111
 
@@ -1151,6 +1457,7 @@ class StrategyManager:
1151
  teaching = contract.get("teaching_strategy", {})
1152
  engagement = contract.get("engagement_strategy", {})
1153
  remedial = contract.get("remedial_strategy", {})
 
1154
  age_profile = engagement.get("age_profile", {})
1155
  return f"""
1156
  أنت تؤدي دور المعلم المباشر داخل منصة Divid Teacher.
@@ -1161,6 +1468,9 @@ class StrategyManager:
1161
  مستوى التدخل: {contract.get('intervention_level', '')}
1162
  فئة الخطأ: {remedial.get('error_category', '')}
1163
 
 
 
 
1164
  نبرة المعلم:
1165
  {teaching.get('teacher_tone', '')}
1166
 
@@ -1180,6 +1490,10 @@ class StrategyManager:
1180
  - لا تعرض الحل الكامل قبل أن يحاول الطالب في التدريب الموجه.
1181
  - لا تمنح الإتقان ولا تعد الطالب بفتح الوسام؛ القرار لمدير التقدم.
1182
  - استخدم سؤالًا جديدًا عند التحقق، ولا تكرر سؤال التشخيص.
 
 
 
 
1183
  """.strip()
1184
 
1185
  # أغلفة التوافق القديمة. الأسماء محفوظة، لكن النص محايد عن اسم النموذج.
@@ -1258,6 +1572,9 @@ class StrategyManager:
1258
  warnings.append("سياسة الإتقان ليست 100 بالمئة.")
1259
  if strategy.get("media_strategy", {}).get("avoid_text_only_slides") is not True:
1260
  warnings.append("لم تُفعّل قاعدة منع الشرائح النصية وحدها.")
 
 
 
1261
  return {
1262
  "valid": not errors,
1263
  "errors": errors,
 
40
  except Exception:
41
  SYSTEM_FOLDER = "system"
42
 
43
+ try:
44
+ from question_clarification_manager import QuestionClarificationManager
45
+ except Exception:
46
+ QuestionClarificationManager = None
47
 
48
+
49
+ STRATEGY_MANAGER_VERSION = "strategy_manager_v5_math_concepts_symbols_contract"
50
+ STRATEGY_SCHEMA_VERSION = "strategy_schema_v4"
51
 
52
 
53
  class StrategyManager:
 
104
  self.skills_manager_error = ""
105
  self._load_skills_manager()
106
 
107
+ self.concept_manager = None
108
+ self.concept_manager_error = ""
109
+ self._load_concept_manager()
110
+
111
  self._ensure_strategy_files()
112
 
113
  # =========================================================
 
189
  digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:14]
190
  return f"strategy_{digest}"
191
 
192
+
193
+ # =========================================================
194
+ # قاموس المفاهيم والرموز
195
+ # =========================================================
196
+
197
+ def _load_concept_manager(self) -> None:
198
+ if QuestionClarificationManager is None:
199
+ self.concept_manager = None
200
+ self.concept_manager_error = (
201
+ "question_clarification_manager.py غير متاح."
202
+ )
203
+ return
204
+ try:
205
+ self.concept_manager = QuestionClarificationManager(
206
+ root_dir=self.root_dir
207
+ )
208
+ self.concept_manager_error = ""
209
+ except Exception as exc:
210
+ self.concept_manager = None
211
+ self.concept_manager_error = repr(exc)
212
+
213
+ def get_concept_strategy(self, skill_id: str) -> Dict[str, Any]:
214
+ skill_key = str(skill_id or "").strip()
215
+ if not skill_key or self.concept_manager is None:
216
+ return {
217
+ "enabled": False,
218
+ "skill_id": skill_key,
219
+ "concepts": [],
220
+ "symbols": [],
221
+ "requirements": {},
222
+ "error": self.concept_manager_error,
223
+ }
224
+
225
+ package = self.concept_manager.get_remedial_concept_package(skill_key)
226
+ concepts = package.get("concepts") or []
227
+ symbols = package.get("symbols") or []
228
+
229
+ concept_cards = []
230
+ for concept in concepts:
231
+ concept_cards.append(
232
+ {
233
+ "concept_id": concept.get("concept_id"),
234
+ "canonical_name_ar": concept.get("canonical_name_ar"),
235
+ "name_en": concept.get("name_en"),
236
+ "aliases_ar": concept.get("aliases_ar") or [],
237
+ "student_explanation_ar": (
238
+ concept.get("student_friendly_explanation_ar")
239
+ or concept.get("definition_ar")
240
+ ),
241
+ "common_confusions": concept.get("common_confusions") or [],
242
+ "remedial_explanation": (
243
+ concept.get("remedial_explanation") or {}
244
+ ),
245
+ "media_support": concept.get("media_support") or {},
246
+ }
247
+ )
248
+
249
+ symbol_cards = []
250
+ for symbol in symbols:
251
+ symbol_cards.append(
252
+ {
253
+ "symbol_id": symbol.get("symbol_id"),
254
+ "display": symbol.get("display"),
255
+ "name_ar": symbol.get("name_ar"),
256
+ "spoken_ar": symbol.get("spoken_ar"),
257
+ "student_definition_ar": (
258
+ symbol.get("student_definition_ar")
259
+ ),
260
+ "common_confusions": (
261
+ symbol.get("common_confusions") or []
262
+ ),
263
+ "examples": symbol.get("examples") or [],
264
+ "remedial_explanation": (
265
+ symbol.get("remedial_explanation") or {}
266
+ ),
267
+ }
268
+ )
269
+
270
+ return {
271
+ "enabled": bool(concept_cards or symbol_cards),
272
+ "skill_id": skill_key,
273
+ "concepts": concept_cards,
274
+ "symbols": symbol_cards,
275
+ "requirements": {
276
+ "explain_concept_before_procedure": True,
277
+ "mention_common_aliases": True,
278
+ "explain_every_symbol_before_first_use": True,
279
+ "pronounce_symbol_aloud": True,
280
+ "distinguish_similar_symbols": True,
281
+ "text_explanation_required": True,
282
+ "audio_explanation_supported": True,
283
+ "micro_video_supported": True,
284
+ "visual_representation_required_when_helpful": True,
285
+ "concept_check_required": True,
286
+ "symbol_check_required_when_symbols_exist": True,
287
+ },
288
+ "source": "math_concepts_global_and_math_symbols_global",
289
+ }
290
+
291
  # =========================================================
292
  # القراءة والكتابة والهجرة
293
  # =========================================================
 
918
  # بناء عقد الاستراتيجية
919
  # =========================================================
920
 
921
+
922
+ # =========================================================
923
+ # مستوى المحتوى مقابل عمر الطالب
924
+ # =========================================================
925
+
926
+ def infer_instructional_stage(
927
+ self,
928
+ skill_id: Any,
929
+ skill_context: Optional[Dict[str, Any]] = None,
930
+ lesson_context: Optional[Dict[str, Any]] = None,
931
+ ) -> str:
932
+ """
933
+ يحدد مستوى المفهوم نفسه، لا عمر الطالب.
934
+
935
+ قد يكون الطالب في الصف التاسع، لكنه يعالج مهارة من الروضة.
936
+ عندها نبسط المفهوم، مع الحفاظ على نبرة محترمة تناسب عمره.
937
+ """
938
+ context = skill_context if isinstance(skill_context, dict) else {}
939
+ lesson = lesson_context if isinstance(lesson_context, dict) else {}
940
+
941
+ candidates = [
942
+ context.get("grade"),
943
+ context.get("grade_level"),
944
+ context.get("grade_min"),
945
+ context.get("expected_grade_min"),
946
+ context.get("grade_band"),
947
+ lesson.get("skill_grade"),
948
+ lesson.get("global_level"),
949
+ skill_id,
950
+ ]
951
+ joined = " ".join(str(item or "") for item in candidates).lower()
952
+
953
+ if any(token in joined for token in (
954
+ "grade_0", "kindergarten", "early_foundation", "-k-", "_k_",
955
+ "count-to-20", "count_to_20", "cc-count"
956
+ )):
957
+ return "early_foundation"
958
+ if any(token in joined for token in (
959
+ "grade_1", "grade_2", "grade_3", "early_primary"
960
+ )):
961
+ return "early_primary"
962
+ if any(token in joined for token in (
963
+ "grade_4", "grade_5", "grade_6", "upper_primary"
964
+ )):
965
+ return "upper_primary"
966
+ if any(token in joined for token in (
967
+ "grade_7", "grade_8", "grade_9", "middle_school"
968
+ )):
969
+ return "middle_school"
970
+ if any(token in joined for token in (
971
+ "grade_10", "grade_11", "grade_12", "high_school"
972
+ )):
973
+ return "high_school"
974
+ return "unknown_stage"
975
+
976
+ def build_audience_profile(
977
+ self,
978
+ student: Dict[str, Any],
979
+ skill_id: Any,
980
+ skill_context: Optional[Dict[str, Any]] = None,
981
+ lesson_context: Optional[Dict[str, Any]] = None,
982
+ ) -> Dict[str, Any]:
983
+ student = student if isinstance(student, dict) else {}
984
+ chronological_group = self.get_student_age_group(student)
985
+ instructional_stage = self.infer_instructional_stage(
986
+ skill_id=skill_id,
987
+ skill_context=skill_context,
988
+ lesson_context=lesson_context,
989
+ )
990
+
991
+ is_foundation = instructional_stage in {
992
+ "early_foundation",
993
+ "early_primary",
994
+ }
995
+ is_young_student = chronological_group in {
996
+ "young",
997
+ "early_childhood",
998
+ }
999
+
1000
+ if is_foundation and is_young_student:
1001
+ delivery_mode = "child_friendly_concrete"
1002
+ teacher_tone = "دافئ، بسيط، مشجع، ويستخدم كلمات قصيرة"
1003
+ max_sentence_words = 9
1004
+ vocabulary_level = "very_simple"
1005
+ examples_style = "أشياء محسوسة وعدّ بصري وحركة واضحة"
1006
+ elif is_foundation:
1007
+ delivery_mode = "respectful_foundation_repair"
1008
+ teacher_tone = "محترم، مباشر، بسيط، من دون طفولية أو تعقيد"
1009
+ max_sentence_words = 12
1010
+ vocabulary_level = "simple_respectful"
1011
+ examples_style = "تمثيل محسوس سريع ثم ربط بالرمز"
1012
+ else:
1013
+ delivery_mode = "age_aligned_instruction"
1014
+ teacher_tone = "ودود ومباشر"
1015
+ max_sentence_words = 18
1016
+ vocabulary_level = "grade_appropriate"
1017
+ examples_style = "مثال واضح ثم تدريب"
1018
+
1019
+ return {
1020
+ "chronological_age_group": chronological_group,
1021
+ "instructional_stage": instructional_stage,
1022
+ "delivery_mode": delivery_mode,
1023
+ "teacher_tone": teacher_tone,
1024
+ "max_sentence_words": max_sentence_words,
1025
+ "vocabulary_level": vocabulary_level,
1026
+ "examples_style": examples_style,
1027
+ "concrete_first": bool(is_foundation),
1028
+ "one_action_per_sentence": bool(is_foundation),
1029
+ "use_call_and_response": bool(is_foundation),
1030
+ "avoid_abstract_meta_language": True,
1031
+ "avoid_philosophical_filler": True,
1032
+ "avoid_childish_tone_for_older_student": bool(
1033
+ is_foundation and not is_young_student
1034
+ ),
1035
+ "forbidden_phrases": [
1036
+ "ركز على معنى كل خطوة",
1037
+ "قل لنفسك ما الذي تغير ولماذا",
1038
+ "الهدف ليس حفظ الإجابة",
1039
+ "ابن الحل من أول نقطة مفهومة",
1040
+ "هذا دليل مهم على أن الفكرة أصبحت مستقرة",
1041
+ ],
1042
+ }
1043
+
1044
  def choose_strategy(
1045
  self,
1046
  student: Optional[Dict[str, Any]],
 
1088
  }
1089
 
1090
  skill_context = self.get_skill_context(skill_id)
1091
+ concept_strategy = self.get_concept_strategy(skill_id)
1092
  taxonomy_title = self._skill_title(skill_context, gap_skill or lesson_title)
1093
  skill_title = taxonomy_title or gap_skill or lesson_title or "المهارة المستهدفة"
1094
  profile = skill_context.get("diagnostic_profile", {})
1095
+ audience_profile = self.build_audience_profile(
1096
+ student=student if isinstance(student, dict) else {},
1097
+ skill_id=skill_id,
1098
+ skill_context=skill_context,
1099
+ lesson_context=lesson_context,
1100
+ )
1101
 
1102
  selected_error = str(error_type or lesson_context.get("error_type") or "").strip()
1103
  if not selected_error and isinstance(profile, dict):
 
1149
  media_requirements = {
1150
  "required": True,
1151
  "representations": representations,
1152
+ "concept_and_symbol_requirements": (
1153
+ concept_strategy.get("requirements") or {}
1154
+ ),
1155
+ "concept_media_cards": [
1156
+ {
1157
+ "concept_id": item.get("concept_id"),
1158
+ "name_ar": item.get("canonical_name_ar"),
1159
+ "media_support": item.get("media_support") or {},
1160
+ }
1161
+ for item in (concept_strategy.get("concepts") or [])
1162
+ ],
1163
+ "symbol_media_cards": [
1164
+ {
1165
+ "symbol_id": item.get("symbol_id"),
1166
+ "display": item.get("display"),
1167
+ "spoken_ar": item.get("spoken_ar"),
1168
+ "meaning_ar": item.get("student_definition_ar"),
1169
+ }
1170
+ for item in (concept_strategy.get("symbols") or [])
1171
+ ],
1172
  "dynamic_visual_preferred": any(
1173
  method in {"misconception_repair", "multiple_representations", "concrete_representational_abstract", "inquiry_science"}
1174
  for method in methods
 
1192
  "must_include": [
1193
  "learning_objective", "teaching_method", "worked_example",
1194
  "guided_practice", "checkpoint_or_closure", "failure_path",
1195
+ "media_requirements", "concept_explanation",
1196
+ "symbol_explanation_before_first_use",
1197
  ],
1198
  "must_not_include": [
1199
  "generic_long_lecture", "student_private_data", "model_names",
 
1204
  teaching_strategy = {
1205
  "methods": methods,
1206
  "method_details": method_details,
1207
+ "teacher_tone": audience_profile.get(
1208
+ "teacher_tone",
1209
+ age_profile.get("teacher_tone", "ودود ومباشر"),
1210
+ ),
1211
  "lesson_style": age_profile.get("lesson_style", "شرح ثم مثال ثم تدريب"),
1212
+ "max_teacher_sentence_words": audience_profile.get(
1213
+ "max_sentence_words",
1214
+ age_profile.get("max_teacher_sentence_words", 18),
1215
+ ),
1216
+ "vocabulary_level": audience_profile.get("vocabulary_level"),
1217
+ "delivery_mode": audience_profile.get("delivery_mode"),
1218
+ "examples_style": audience_profile.get("examples_style"),
1219
+ "concrete_first": audience_profile.get("concrete_first", False),
1220
+ "one_action_per_sentence": audience_profile.get(
1221
+ "one_action_per_sentence", False
1222
+ ),
1223
+ "avoid_abstract_meta_language": True,
1224
+ "avoid_philosophical_filler": True,
1225
+ "forbidden_phrases": audience_profile.get("forbidden_phrases", []),
1226
  "representations": representations,
1227
  "feedback_rule": "فسّر سبب الخطأ وقدّم الخطوة التالية المناسبة دون وصف الطالب بالضعف.",
1228
  "interaction_rule": "اعرض سؤالًا واحدًا في كل مرة وانتظر إجابة الطالب.",
1229
+ "concept_strategy": concept_strategy,
1230
+ "concept_rule": (
1231
+ "اشرح معنى المفهوم وأسماءه البديلة قبل الإجراء، "
1232
+ "واشرح الرمز ونطقه قبل أول استخدام."
1233
+ ),
1234
  }
1235
 
1236
  remedial_strategy = {
 
1261
  engagement_strategy = {
1262
  "age_group": age_group,
1263
  "age_profile": age_profile,
1264
+ "audience_profile": audience_profile,
1265
+ "chronological_age_group": audience_profile.get(
1266
+ "chronological_age_group"
1267
+ ),
1268
+ "instructional_stage": audience_profile.get(
1269
+ "instructional_stage"
1270
+ ),
1271
+ "delivery_mode": audience_profile.get("delivery_mode"),
1272
  "attention_policy": deepcopy(strategies.get("attention_policy", {})),
1273
  "pomodoro": deepcopy(strategies.get("pomodoro", {})),
1274
  }
 
1306
  "remedial_strategy": remedial_strategy,
1307
  "assessment_strategy": assessment_strategy,
1308
  "media_strategy": media_requirements,
1309
+ "concept_strategy": concept_strategy,
1310
  "engagement_strategy": engagement_strategy,
1311
  "taxonomy_context": skill_context,
1312
+ "audience_profile": audience_profile,
1313
+ "instructional_stage": audience_profile.get(
1314
+ "instructional_stage"
1315
+ ),
1316
+ "delivery_mode": audience_profile.get("delivery_mode"),
1317
  }
1318
 
1319
  planner_instruction = self.build_planner_instruction(contract)
 
1387
  remedial = contract.get("remedial_strategy", {})
1388
  assessment = contract.get("assessment_strategy", {})
1389
  media = contract.get("media_strategy", {})
1390
+ concept_strategy = contract.get("concept_strategy", {})
1391
  return f"""
1392
  أنت تؤدي دور مخطط تعليمي داخل منصة Divid Teacher.
1393
  يجب أن تبني الخطة من عقد الاستراتيجية الآتي، ولا يجوز تجاهله أو استبداله بخطة عامة.
 
1401
  فئة الخطأ: {remedial.get('error_category', '')}
1402
  المحاولة العلاجية: {remedial.get('route', '')}
1403
 
1404
+ مفاهيم المهارة ورموزها:
1405
+ {json.dumps(concept_strategy, ensure_ascii=False, indent=2)}
1406
+
1407
+ قواعد المفاهيم والرموز:
1408
+ - ابدأ بمعنى المفهوم قبل الإجراء الحسابي.
1409
+ - اذكر الاسم القياسي والأسماء المدرسية البديلة عند الحاجة.
1410
+ - اشرح نطق كل رمز ومعناه قبل أول ظهور له.
1411
+ - فرّق بصريًا ولفظيًا بين الرموز المتشابهة.
1412
+ - اجعل الشرح النصي والصوتي والفيديو مبنيًا على هذه البطاقات.
1413
+ - اختم بسؤال تحقق للمفهوم، وللرمز عند وجود رمز.
1414
+
1415
  طرق التدريس الملزمة:
1416
  {self._format_list(teaching.get('methods', []))}
1417
 
 
1457
  teaching = contract.get("teaching_strategy", {})
1458
  engagement = contract.get("engagement_strategy", {})
1459
  remedial = contract.get("remedial_strategy", {})
1460
+ concept_strategy = contract.get("concept_strategy", {})
1461
  age_profile = engagement.get("age_profile", {})
1462
  return f"""
1463
  أنت تؤدي دور المعلم المباشر داخل منصة Divid Teacher.
 
1468
  مستوى التدخل: {contract.get('intervention_level', '')}
1469
  فئة الخطأ: {remedial.get('error_category', '')}
1470
 
1471
+ قاموس المفاهيم والرموز الملزم:
1472
+ {json.dumps(concept_strategy, ensure_ascii=False, indent=2)}
1473
+
1474
  نبرة المعلم:
1475
  {teaching.get('teacher_tone', '')}
1476
 
 
1490
  - لا تعرض الحل الكامل قبل أن يحاول الطالب في التدريب الموجه.
1491
  - لا تمنح الإتقان ولا تعد الطالب بفتح الوسام؛ القرار لمدير التقدم.
1492
  - استخدم سؤالًا جديدًا عند التحقق، ولا تكرر سؤال التشخيص.
1493
+ - لا تستخدم مفهومًا أو رمزًا قبل شرح معناه ونطقه.
1494
+ - اذكر الاسم البديل إذا كان المصطلح القياسي غير مألوف للطالب.
1495
+ - لا تحول بطاقة المفهوم إلى محاضرة؛ استخدم تعريفًا قصيرًا وتمثيلًا ومثالًا.
1496
+ - يجب أن يشرح الفيديو أو الصوت المفهوم والرمز، لا أن يعرضهما كزخرفة.
1497
  """.strip()
1498
 
1499
  # أغلفة التوافق القديمة. الأسماء محفوظة، لكن النص محايد عن اسم النموذج.
 
1572
  warnings.append("سياسة الإتقان ليست 100 بالمئة.")
1573
  if strategy.get("media_strategy", {}).get("avoid_text_only_slides") is not True:
1574
  warnings.append("لم تُفعّل قاعدة منع الشرائح النصية وحدها.")
1575
+ concept_strategy = strategy.get("concept_strategy", {})
1576
+ if strategy.get("subject") == "math" and not concept_strategy:
1577
+ warnings.append("لم تُرفق استراتيجية المفاهيم والرموز.")
1578
  return {
1579
  "valid": not errors,
1580
  "errors": errors,