Spaces:
Running on Zero
Running on Zero
Upload 11 files
Browse files- app.py +106 -37
- badge_game_manager.py +102 -65
- deepmind_compatible_generator.py +54 -0
- external_question_import_manager.py +103 -0
- question_bank_manager.py +1063 -4
- question_clarification_manager.py +439 -0
- strategy_manager.py +322 -5
app.py
CHANGED
|
@@ -35,12 +35,12 @@ import re
|
|
| 35 |
import sys
|
| 36 |
from pathlib import Path
|
| 37 |
|
| 38 |
-
BRIDGE_VERSION = "
|
| 39 |
-
BUILD_ID = "
|
| 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/
|
| 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 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 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 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
value = set_math_whiteboard_task_from_dict(task, current_board_value)
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
)
|
| 437 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
except Exception as e:
|
| 439 |
-
return current_board_value or gr.skip(), profile, f"تعذر تحميل
|
| 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 |
-
|
|
|
|
| 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: `
|
| 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: `
|
| 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 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"
|
| 766 |
-
"
|
|
|
|
|
|
|
|
|
|
| 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": "
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": "
|
| 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 |
-
) ->
|
|
|
|
|
|
|
| 1001 |
if isinstance(answer, dict):
|
| 1002 |
-
|
| 1003 |
-
|
| 1004 |
-
|
| 1005 |
-
|
| 1006 |
-
|
| 1007 |
-
|
| 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("
|
| 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 =
|
| 1182 |
-
|
| 1183 |
-
|
| 1184 |
-
|
| 1185 |
-
).strip()
|
| 1186 |
|
| 1187 |
-
unknown =
|
| 1188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1175 |
_LOCK=threading.Lock()
|
| 1176 |
|
| 1177 |
-
def get_manager() ->
|
| 1178 |
global _MANAGER
|
| 1179 |
with _LOCK:
|
| 1180 |
-
if _MANAGER is None: _MANAGER=
|
| 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 |
-
|
| 45 |
-
|
|
|
|
| 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":
|
|
|
|
|
|
|
|
|
|
| 949 |
"lesson_style": age_profile.get("lesson_style", "شرح ثم مثال ثم تدريب"),
|
| 950 |
-
"max_teacher_sentence_words":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|