التعليم والبحث العلمي الأدلة التقنية

تتبع تقدم المتعلم Learner Tracking من SCORM إلى xAPI

حزمة SCORM حقيقية في Moodle، ثم جمل xAPI في LRS مفتوح المصدر، ثم جلسة cmi5، لتعرف أي معيار تطلبه من مزود المحتوى.

تتبع تقدم المتعلم Learner Tracking من SCORM إلى xAPI

تشتري الجامعة مقرراً جاهزاً من مزود محتوى، أو تطلب من فريق التصميم التعليمي أن يبني وحدة تفاعلية في أداة مثل Articulate، فيأتي السؤال الأول من المزود: هل تريدونه SCORM 1.2 أم SCORM 2004 أم xAPI أم cmi5؟ والإجابة الخاطئة تعني أن الحزمة تعمل في المتصفح ولكن الدرجة لا تصل إلى سجل الدرجات، أو تصل ناقصة. في مقال أنظمة إدارة التعلم LMS ذكرنا أن المنصة يجب أن تستقبل المحتوى من الخارج، وفي هذا المقال سوف نأخذ هذا الجزء بالتجربة، وسوف نتناول:

  • ما الذي يحل كل معيار من الثلاثة، وكيف تنتقل بيانات المتعلم في كل منها.
  • حزمة SCORM 1.2 كتبناها بأنفسنا، رفعناها إلى Moodle وحلها طالب، وأين تخزن الدرجة.
  • مخزن سجلات التعلم LRS مفتوح المصدر، وإضافة ترسل أحداث Moodle إليه بصيغة xAPI، وخطأ وجدناه في الترجمة.
  • جلسة cmi5 كاملة بالترتيب الذي يفرضه المعيار، وما يرفضه ال LRS.
  • جدول للاختيار: ماذا تطلب من مزود المحتوى.

لماذا نحتاج معياراً أصلاً؟

المحتوى التعليمي التفاعلي صفحة HTML وJavaScript في النهاية، والطريقة البسيطة أن ترفع الصفحة إلى أي سيرفر وتضع رابطها في المقرر، وهذا يعمل للعرض فقط، فال LMS لا يعرف من فتح الصفحة، ولا ماذا أجاب، ولا متى انتهى. والحل التالي أن يكتب كل مزود محتوى إضافة خاصة لكل LMS، وهذا غير مناسب إطلاقاً، لأن عندنا عشرات المنصات ومئات المزودين. لذلك ظهرت المعايير، وكلها تجيب عن سؤالين: كيف يطلق ال LMS المحتوى، وكيف يرسل المحتوى نتيجة المتعلم. والصورة التالية تبين كيف يجيب كل معيار:

مخطط ثلاثة نماذج لنقل بيانات المتعلم: SCORM حيث الحزمة SCO داخل iframe في صفحة ال LMS في المتصفح تستدعي API.LMSSetValue وهو كائن JavaScript يضعه ال LMS في النافذة الأم، والبيانات تذهب إلى قاعدة بيانات ال LMS مثل cmi.core.score.raw تساوي 67، وxAPI حيث أي برنامج مثل LMS أو تطبيق جوال أو محاكاة يرسل POST /xapi/statements إلى مخزن سجلات التعلم LRS بصيغة actor وverb وobject وresult، وcmi5 حيث ال LMS يطلق الوحدة AU برابط فيه endpoint وfetch وregistration، والوحدة تطلب توكن مرة واحدة ثم ترسل جمل xAPI بترتيب محدد إلى ال LRS الخاص بال LMS من launched إلى terminated
SCORM يربط المحتوى بالمتصفح وال LMS، وxAPI يحرره منهما، وcmi5 يضع قواعد للإطلاق والنتيجة فوق xAPI
  • SCORM: الحزمة ملف ZIP فيه ملف وصف اسمه imsmanifest.xml وصفحات HTML، وال LMS يفتح الصفحة داخل إطار iframe، ويضع في النافذة الأم كائن JavaScript اسمه API في الإصدار 1.2، أو API_1484_11 في الإصدار 2004، والمحتوى يستدعي دواله ليكتب قيماً مثل cmi.core.score.raw. لذلك يعمل SCORM داخل المتصفح وداخل ال LMS فقط.
  • xAPI: واسمه القديم Tin Can API، لا يشترط متصفحاً ولا LMS، فأي برنامج يرسل جملاً Statements بصيغة JSON عبر HTTP إلى مخزن سجلات التعلم Learning Record Store واختصاراً LRS، وكل جملة فيها من فعل ماذا بماذا وما النتيجة. والإصدار المستخدم على نطاق واسع هو 1.0.3، ومستودع المواصفة يذكر أن الإصدار الحالي هو 2.0 الذي أصبح معياراً من IEEE.
  • cmi5: ليس معياراً منفصلاً عن xAPI، وإنما ملف تعريف Profile فوقه، ومواصفته صدرت بإصدار Quartz في 1 يونيو 2016، وتعيد ما فقده xAPI من SCORM: ال LMS يطلق المحتوى، ويعطيه عنوان ال LRS وتوكن مؤقت، والمحتوى يرسل جملاً محددة بترتيب محدد.

وقد يتساءل البعض: إذا كان xAPI أحدث وأعم، فلماذا ما زال SCORM منتشراً؟ والإجابة أن كل أدوات التأليف تصدر SCORM، وكل LMS يقرؤه، وهو يكفي لأكثر حالة شائعة: وحدة واحدة يفتحها الطالب داخل المقرر ويحلها ويأخذ درجة، وأما إذا أردت أن تسجل ما يحدث خارج ال LMS، في تطبيق جوال أو محاكاة أو معمل، فهنا يأتي xAPI.

حزمة SCORM 1.2 للتجربة

بدلاً من حزمة جاهزة من مزود لا نعرف ما بداخلها، كتبنا حزمة صغيرة فيها اختبار من ثلاثة أسئلة عن DNS، ونسبة النجاح فيها 70%. الملف الأول هو imsmanifest.xml:

<?xml version="1.0" encoding="UTF-8"?>
<manifest identifier="arabroot.dns.quiz" version="1.0"
  xmlns="http://www.imsproject.org/xsd/imscp_rootv1p1p2"
  xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_rootv1p2">
  <metadata>
    <schema>ADL SCORM</schema>
    <schemaversion>1.2</schemaversion>
  </metadata>
  <organizations default="org1">
    <organization identifier="org1">
      <title>DNS Basics</title>
      <item identifier="item1" identifierref="res1">
        <title>DNS Quiz</title>
        <adlcp:masteryscore>70</adlcp:masteryscore>
      </item>
    </organization>
  </organizations>
  <resources>
    <resource identifier="res1" type="webcontent" adlcp:scormtype="sco" href="index.html">
      <file href="index.html"/>
    </resource>
  </resources>
</manifest>

والملف الثاني index.html، وهو الاختبار نفسه مع الجزء الذي يتكلم مع ال LMS:

<!doctype html>
<html lang="ar" dir="rtl">
<head><meta charset="utf-8"><title>DNS Quiz</title>
<style>body{font-family:sans-serif;max-width:640px;margin:24px auto;line-height:1.8}fieldset{margin:12px 0}#out{font-weight:bold}</style>
</head>
<body>
<h2>اختبار قصير: أساسيات DNS</h2>
<form id="q">
  <fieldset><legend>1. ما وظيفة DNS؟</legend>
    <label><input type="radio" name="q1" value="a"> تحويل اسم النطاق إلى عنوان IP</label><br>
    <label><input type="radio" name="q1" value="b"> توزيع عناوين IP على الأجهزة</label></fieldset>
  <fieldset><legend>2. ما نوع السجل الذي يربط الاسم بعنوان IPv4؟</legend>
    <label><input type="radio" name="q2" value="a"> MX</label><br>
    <label><input type="radio" name="q2" value="b"> A</label></fieldset>
  <fieldset><legend>3. ما المنفذ الافتراضي لـ DNS؟</legend>
    <label><input type="radio" name="q3" value="a"> 53</label><br>
    <label><input type="radio" name="q3" value="b"> 443</label></fieldset>
  <button type="submit">سلم الإجابات</button>
</form>
<p id="out"></p>
<script>
// Find the SCORM 1.2 API object that the LMS places in a parent window
function findAPI(w) { for (let i = 0; i < 10 && w; i++) { if (w.API) return w.API; if (w.parent === w) break; w = w.parent; } return null; }
const api = findAPI(window) || (window.opener && findAPI(window.opener));
const started = Date.now();
if (api) api.LMSInitialize('');
const answers = { q1: 'a', q2: 'b', q3: 'a' };
document.getElementById('q').addEventListener('submit', (ev) => {
  ev.preventDefault();
  const f = new FormData(ev.target);
  const correct = Object.keys(answers).filter(k => f.get(k) === answers[k]).length;
  const score = Math.round(correct / 3 * 100);
  const secs = Math.round((Date.now() - started) / 1000);
  const t = `${String(Math.floor(secs / 3600)).padStart(4, '0')}:${String(Math.floor(secs / 60) % 60).padStart(2, '0')}:${String(secs % 60).padStart(2, '0')}`;
  if (api) {
    api.LMSSetValue('cmi.core.score.raw', String(score));
    api.LMSSetValue('cmi.core.score.min', '0');
    api.LMSSetValue('cmi.core.score.max', '100');
    api.LMSSetValue('cmi.core.lesson_status', score >= 70 ? 'passed' : 'failed');
    api.LMSSetValue('cmi.core.session_time', t);
    api.LMSCommit('');
    api.LMSFinish('');
  }
  document.getElementById('out').textContent = `النتيجة: ${score}% (${correct} من 3)` + (api ? '' : ' (لم يعثر على LMS)');
});
</script>
</body>
</html>

تفاصيل الحزمة وإصدارات SCORM ودوال ال API شرحناها في المقال التمهيدي عن SCORM، والذي يهمنا هنا للمقارنة نقطتان: الدالة findAPI تصعد في النوافذ الأم حتى تجد الكائن API، لذلك لا تعمل الحزمة خارج ال LMS، فهي تعرض النتيجة ولكنها لا تجد من يرسلها إليه، والحزمة نفسها هي التي تقرر النجاح بمقارنة الدرجة بالرقم 70، وهذا ما سوف يتغير في cmi5.

ثم ضغطنا الملفين في dns-quiz-scorm12.zip، والمهم أن يكون imsmanifest.xml في جذر الملف المضغوط وليس داخل مجلد، وإلا رفض Moodle الحزمة.

الحزمة في Moodle

قبل أن نرفع الحزمة لاحظ ما يقوله سؤال وجواب SCORM في توثيق Moodle: يدعم Moodle الإصدار SCORM 1.2 ويجتاز اختبارات المطابقة له، ويدعم AICC، ولا يدعم SCORM 2004، والتطوير الأصلي له متوقف، والحل المقترح لمن يحتاجه إضافة تجارية تربط بخدمة SCORM Cloud. لذلك إذا كانت منصتك Moodle فاطلب من المزود SCORM 1.2 صراحة.

المختبر هو نفسه الذي شرحناه في دليل تثبيت Moodle مع ثلاثة فروق: الإصدار 5.2.4 وليس 5.3، والسبب سوف يأتي في قسم xAPI، وContainer إضافي لل LRS، والمنفذ 31080. وهذا ملف compose.yaml الكامل:

name: edu5-lms
services:
  db:
    image: postgres:17.11
    container_name: edu5-lms-db
    restart: unless-stopped
    mem_limit: 512m
    environment:
      POSTGRES_DB: moodle
      POSTGRES_USER: moodle
      POSTGRES_PASSWORD: change-me-db
    volumes:
      - db:/var/lib/postgresql/data

  web:
    image: moodlehq/moodle-php-apache:8.3-bookworm@sha256:501f19fa75ad3cdc8962f2a742f39d179b8aa8ffdc2ff233d8d27a32f14e0e05
    container_name: edu5-lms-web
    restart: unless-stopped
    mem_limit: 768m
    depends_on: [db]
    environment:
      PHP_INI-max_input_vars: "5000"
      PHP_INI-display_errors: "Off"
    ports:
      - "127.0.0.1:31080:80"
    volumes:
      - code:/var/www/html
      - data:/var/www/moodledata
      - ./moodle-router.conf:/etc/apache2/conf-enabled/moodle-router.conf:ro

  lrs:
    image: yetanalytics/lrsql:v0.9.9
    container_name: edu5-lms-lrs
    restart: unless-stopped
    mem_limit: 512m
    environment:
      LRSQL_ADMIN_USER_DEFAULT: lrsadmin
      LRSQL_ADMIN_PASS_DEFAULT: change-me-lrs-admin
      LRSQL_API_KEY_DEFAULT: moodle-key
      LRSQL_API_SECRET_DEFAULT: change-me-lrs-secret
      LRSQL_ALLOW_ALL_ORIGINS: "true"
    ports:
      - "127.0.0.1:31090:8080"
    volumes:
      - lrs:/lrsql/db

volumes:
  db:
    name: edu5-lms-db
  code:
    name: edu5-lms-code
  data:
    name: edu5-lms-data
  lrs:
    name: edu5-lms-lrs

أنشأنا المقرر «مقدمة في الشبكات» والطالب خالد ونشاط SCORM من الحزمة بسكربت PHP يستخدم مولد البيانات في Moodle، وهو ما تفعله أنت من الواجهة بإضافة نشاط من نوع «حزمة سكورم» ورفع الملف. ثم دخلنا بحساب الطالب وفتحنا النشاط، والصورة التالية للحزمة داخل مشغل Moodle قبل التسليم:

مشغل SCORM في Moodle بالعربية: عنوان النشاط اختبار DNS القصير (SCORM 1.2)، وزر الخروج من النشاط، وعلى اليسار شجرة الحزمة DNS Basics وتحتها DNS Quiz، وفي الوسط الاختبار من الحزمة بعنوان اختبار قصير أساسيات DNS وثلاثة أسئلة بخيارات، والطالب اختار تحويل اسم النطاق إلى عنوان IP، والسجل A، والمنفذ 443، وزر سلم الإجابات
الحزمة داخل مشغل Moodle، والطالب أخطأ عمداً في السؤال الثالث

أجاب الطالب عن سؤالين صحيحاً واختار 443 في الثالث، فعرضت الحزمة «النتيجة: 67% (2 من 3)»، ثم أعاد المحاولة وأجاب الثلاثة صحيحاً فكانت 100%. والسؤال الآن: أين ذهبت هذه القيم؟ في Moodle 5.2 تخزن قيم SCORM في جدولين، scorm_attempt للمحاولة وscorm_scoes_value للقيم، فاستعلمنا عنها مباشرة:

docker exec edu5-lms-db psql -U moodle -d moodle -c "select a.attempt, e.element, v.value, v.scoid from mdl_scorm_attempt a join mdl_scorm_scoes_value v on v.attemptid=a.id join mdl_scorm_element e on e.id=v.elementid where a.userid=3 and e.element in ('cmi.core.lesson_status','cmi.core.score.raw','cmi.core.total_time') order by a.attempt, e.element;"
 attempt |        element         |    value    | scoid
---------+------------------------+-------------+-------
       1 | cmi.core.lesson_status | failed      |     2
       1 | cmi.core.score.raw     | 67          |     2
       1 | cmi.core.total_time    | 00:00:03.00 |     2
       2 | cmi.core.lesson_status | passed      |     2
       2 | cmi.core.score.raw     | 100         |     2
       2 | cmi.core.total_time    | 00:00:04.00 |     2
(6 rows)

في المخرج أعلاه لاحظ التالي:

  • الحالة في المحاولة الأولى failed لأن 67 أقل من 70، وفي الثانية passed، والحزمة هي التي قررت ذلك وليس Moodle.
  • Moodle حول session_time الذي أرسلته الحزمة إلى total_time للمحاولة.
  • العمود scoid قيمته 2، وهو رقم الوحدة SCO داخل الحزمة، وهو غير رقم النشاط في المقرر، وتذكر هذا الرقم لأنه سبب الخطأ في القسم التالي.

والمعلم يرى المحاولتين في تقرير النشاط، والدرجة المعتمدة 100 لأن طريقة التقييم أعلى محاولة:

تقرير نشاط SCORM في Moodle بالعربية: التقرير الأساسي، ونتيجتان لمستخدم واحد، الطالب خالد الطالب ببريد student1@example.com، المحاولة 1 بدأت الأربعاء 21 أكتوبر 2026 والدرجة 67، والمحاولة 2 والدرجة 100
تقرير النشاط: محاولتان، 67 ثم 100

ومن رقم المحاولة يفتح المعلم تفاصيل المسار، وهي قيم cmi نفسها التي أرسلتها الحزمة:

صفحة تفاصيل المسار في Moodle للمحاولة 1 للطالب خالد في DNS Quiz: جدول بالعنصر والقيمة، cmi.core.lesson_status بقيمة failed، وcmi.core.score.max بقيمة 100، وcmi.core.score.min بقيمة 0، وcmi.core.score.raw بقيمة 67، وcmi.core.total_time بقيمة 3 ثواني
تفاصيل المحاولة الأولى: كل ما أرسلته الحزمة عبر LMSSetValue

وهنا حدود SCORM بوضوح: كل ما يعرفه Moodle هو هذه القيم الخمس، فلا يعرف أي سؤال أخطأ فيه الطالب إلا إذا أرسلت الحزمة عناصر التفاعلات cmi.interactions، وأغلب الحزم لا ترسلها، ولا يعرف شيئاً عما يحدث خارج المتصفح.

xAPI: الجمل Statements ومخزن سجلات التعلم LRS

جملة xAPI تشبه جملة عادية: فاعل Actor وفعل Verb ومفعول Object، ونتيجة Result إن وجدت، مثل «خالد أكمل اختبار DNS بدرجة 67». ويحتاج xAPI إلى مكان يستقبل هذه الجمل ويخزنها ويسمح بالبحث فيها، وهو ال LRS، واخترنا له SQL LRS من Yet Analytics، وهو مفتوح المصدر بترخيص Apache-2.0، يعمل على SQLite أو PostgreSQL أو MariaDB، والإصدار 0.9.0 منه أضاف دعم xAPI 2.0. وأول ما نفعله أن نسأله عن الإصدارات التي يقبلها:

curl -s -u moodle-key:change-me-lrs-secret -H 'X-Experience-API-Version: 1.0.3' http://127.0.0.1:31090/xapi/about
{"version":["2.0.0","1.0.3"]}
⚠️
واجهة الإدارة في SQL LRS تعرض بعد الدخول التحذير «Seed credentials should be deleted!»، والسبب أن الحساب والمفتاح اللذين وضعناهما في متغيرات البيئة للمختبر معروفان لكل من يقرأ ملف Compose، فأنشئ في الإنتاج حساباً ومفتاحاً جديدين من الواجهة واحذف الأولين.

إرسال أحداث Moodle إلى ال LRS

Moodle نفسه لا يرسل xAPI إلى LRS خارجي، والتوثيق يحيل في ذلك إلى إضافات من طرف ثالث، وأشهرها Logstore xAPI، وهي مخزن سجلات Log Store يترجم أحداث Moodle إلى جمل xAPI. وآخر إصدار منها 5.1.0، وملاحظات الإصدار تقول إنه يدعم Moodle من 4.5 إلى 5.2، ولهذا السبب جربنا على 5.2.4 وليس 5.3. فككنا الإضافة في public/admin/tool/log/store/xapi، وثبتنا Moodle، ثم فعلناها وضبطناها من سطر الأوامر:

C="docker exec -u www-data edu5-lms-web php /var/www/html/admin/cli/cfg.php"
$C --component=tool_log --name=enabled_stores --set=logstore_standard,logstore_xapi
$C --component=logstore_xapi --name=endpoint --set=http://lrs:8080/xapi/
$C --component=logstore_xapi --name=username --set=moodle-key
$C --component=logstore_xapi --name=password --set=change-me-lrs-secret
$C --component=logstore_xapi --name=backgroundmode --set=0

الخيار backgroundmode بالقيمة 0 يرسل الجملة لحظة حدوث الحدث، وهو مناسب للمختبر لنرى النتيجة فوراً، وأما في الإنتاج فاتركه 1 حتى ترسل المهمة المجدولة الجمل على دفعات Batches، والسبب أن الإرسال اللحظي يجعل كل طلب للطالب ينتظر رد ال LRS، وإذا تعطل ال LRS تأثرت المنصة كلها.

بعد محاولتي الطالب سألنا ال LRS عن الجمل التي وصلته، وهذه هي جمل النشاط بالترتيب، مع اسم حدث Moodle الذي ولد كل جملة:

2026-10-21T13:24:59 completed | \mod_scorm\event\status_submitted | None
2026-10-21T13:24:58 completed | \mod_scorm\event\scoreraw_submitted | {'score': {'raw': 100}}
2026-10-21T13:24:54 launched | \mod_scorm\event\sco_launched | None
2026-10-21T13:24:50 viewed | \mod_scorm\event\course_module_viewed | None

وهذا جزء من جملة الدرجة في المحاولة الأولى كما خزنها ال LRS، حذفنا منه السياق Context لطوله:

{
  "actor": {
    "name": "خالد الطالب",
    "account": {
      "homePage": "http://127.0.0.1:31080",
      "name": "3"
    }
  },
  "verb": {
    "id": "http://adlnet.gov/expapi/verbs/completed",
    "display": {
      "en": "Completed"
    }
  },
  "object": {
    "objectType": "Activity",
    "id": "http://127.0.0.1:31080/mod/scorm/view.php?id=1#sco",
    "definition": {
      "type": "https://xapi.edlm/profiles/edlm-lms/concepts/activity-types/scorm-content-object",
      "name": {
        "ar": "اختبار DNS القصير (SCORM 1.2) Content"
      }
    }
  },
  "result": {
    "score": {
      "raw": 67
    }
  }
}

نجرب الترجمة: failed أصبحت completed

لاحظ الفعل في الجملتين: completed، رغم أن Moodle نفسه سجل المحاولة الأولى failed والثانية passed. فتحنا كود الإضافة لنعرف السبب، والدالة get_scorm_verb فيها فعلاً ثلاث حالات: failed وpassed، وأي قيمة أخرى تصبح completed، وهي تقرأ الحالة من الدالة get_scorm_tracks، وتوقيعها كما يلي:

function get_scorm_tracks(array $config, int $userid, int $scormid, int $scoid, int $attempt)

ولكن الحدث status_submitted يستدعيها هكذا:

    $scormscoestracks = utils\get_scorm_tracks(
        $config,
        $user->id,
        $event->objectid,
        $event->contextinstanceid,
        $unserializedcmi['attemptid']
    );

أي أنه يمرر contextinstanceid، وهو رقم النشاط في المقرر وقيمته 1 في المختبر، مكان رقم الوحدة scoid وقيمته 2 كما رأينا في قاعدة البيانات، فلا تجد الدالة أي قيمة، وتعود الحالة الافتراضية completed. والدرس هنا أكبر من هذه الإضافة: الإضافة التي تترجم من معيار إلى آخر قد تفقد معنى البيانات دون أي رسالة خطأ، فالجملة صحيحة في صيغتها وقبلها ال LRS، ولكنها تقول إن طالباً راسباً أكمل. لذلك لا تقم ببناء لوحة مؤشرات على بيانات xAPI قبل أن تقارن عينة منها بما في ال LMS نفسه.

cmi5: xAPI بقواعد الإطلاق والنتيجة

المشكلة في xAPI الحر أن كل مزود يختار الأفعال والترتيب الذي يريده، فمزود يرسل completed وآخر finished، ولا يعرف ال LMS متى ينتهي المحتوى ولا هل نجح الطالب. وcmi5 يحل ذلك بقواعد واضحة من مواصفته:

  • ملف المقرر cmi5.xml: بدلاً من imsmanifest.xml، يصف الوحدات Assignable Units واختصاراً AU، ولكل وحدة شرط النجاح moveOn ونسبة الإتقان masteryScore.
  • الإطلاق: ال LMS يفتح الوحدة برابط فيه endpoint عنوان ال LRS، وfetch رابط يعطي توكن مرة واحدة فقط بطلب POST، وregistration رقم تسجيل الطالب في المقرر، وactivityId.
  • الأفعال وترتيبها: ال LMS يرسل launched، والوحدة ترسل initialized أولاً وterminated آخراً، وبينهما passed أو failed وcompleted، ولا يسمح المعيار بأكثر من جملة passed واحدة أو completed واحدة في التسجيل الواحد، وfailed لا تأتي بعد passed.

ولا يوجد في Moodle دعم أساسي لcmi5، وتشغيل وحدة cmi5 حقيقية يحتاج LMS يطبق جهة الإطلاق كاملة، لذلك سوف نفعل الجزء الذي يهمك لتفهم المعيار: نرسل جلسة cmi5 كاملة إلى ال LRS بالترتيب والسياق اللذين تطلبهما المواصفة، كما يرسلها LMS ووحدة متوافقان، ثم نقرؤها. والسكربت التالي يفعل ذلك:

// One cmi5 session sent by hand to the LRS, in the order the cmi5 spec requires:
// LMS: launched -> AU: initialized, passed, completed, terminated -> LMS: satisfied (moveOn = CompletedAndPassed).
import { randomUUID } from 'node:crypto';
const LRS = process.env.LRS || 'http://lrs:8080/xapi';
const AUTH = 'Basic ' + Buffer.from(process.env.LRS_AUTH || 'moodle-key:change-me-lrs-secret').toString('base64');
const registration = randomUUID(), sessionid = randomUUID();
const actor = { objectType: 'Agent', name: 'خالد الطالب', account: { homePage: 'https://lms.example.com', name: 'student1' } };
const course = 'https://lms.example.com/cmi5/course/net101';
const au = { objectType: 'Activity', id: 'https://lms.example.com/cmi5/au/net101-dns-quiz',
             definition: { type: 'https://w3id.org/xapi/cmi5/activitytype/au', name: { ar: 'اختبار DNS القصير' } } };
const CMI5 = { id: 'https://w3id.org/xapi/cmi5/context/categories/cmi5' };
const MOVEON = { id: 'https://w3id.org/xapi/cmi5/context/categories/moveon' };
const verb = (v, en) => ({ id: v.startsWith('http') ? v : `http://adlnet.gov/expapi/verbs/${v}`, display: { 'en-US': en } });
const ctx = (cats, extra = {}) => ({ registration, contextActivities: { category: cats, grouping: [{ id: course }] },
  extensions: { 'https://w3id.org/xapi/cmi5/context/extensions/sessionid': sessionid, ...extra } });
const t0 = Date.now(); const at = (s) => new Date(t0 + s * 1000).toISOString();

const statements = [
  { verb: verb('launched', 'Launched'), object: au, timestamp: at(0),
    context: ctx([CMI5], { 'https://w3id.org/xapi/cmi5/context/extensions/launchmode': 'Normal',
      'https://w3id.org/xapi/cmi5/context/extensions/moveon': 'CompletedAndPassed',
      'https://w3id.org/xapi/cmi5/context/extensions/masteryscore': 0.7 }) },
  { verb: verb('initialized', 'Initialized'), object: au, timestamp: at(2), context: ctx([CMI5]) },
  { verb: verb('passed', 'Passed'), object: au, timestamp: at(40), context: ctx([CMI5, MOVEON]),
    result: { score: { scaled: 1.0, raw: 3, min: 0, max: 3 }, success: true, duration: 'PT38S' } },
  { verb: verb('completed', 'Completed'), object: au, timestamp: at(41), context: ctx([CMI5, MOVEON]),
    result: { completion: true, duration: 'PT39S' } },
  { verb: verb('terminated', 'Terminated'), object: au, timestamp: at(42), context: ctx([CMI5]), result: { duration: 'PT40S' } },
  { verb: verb('https://w3id.org/xapi/adl/verbs/satisfied', 'Satisfied'), timestamp: at(43), context: ctx([CMI5]),
    object: { objectType: 'Activity', id: course, definition: { type: 'https://w3id.org/xapi/cmi5/activitytype/course' } } },
].map(s => ({ id: randomUUID(), actor, ...s }));

const res = await fetch(`${LRS}/statements`, { method: 'POST', body: JSON.stringify(statements),
  headers: { Authorization: AUTH, 'Content-Type': 'application/json', 'X-Experience-API-Version': '1.0.3' } });
console.log(`POST /statements -> ${res.status}, ${(await res.json()).length} ids stored, registration ${registration}`);
const back = await (await fetch(`${LRS}/statements?registration=${registration}&ascending=true`,
  { headers: { Authorization: AUTH, 'X-Experience-API-Version': '1.0.3' } })).json();
for (const s of back.statements)
  console.log(`${s.verb.display['en-US'].padEnd(12)} ${s.object.id.split('/').pop().padEnd(16)} ${JSON.stringify(s.result || {})}`);
docker run --rm --network edu5-lms_default -m 256m -v "$PWD":/app:ro -w /app node:24-alpine node cmi5-session.mjs

والمخرج سوف يكون كما يلي:

POST /statements -> 200, 6 ids stored, registration 19b030a9-f80e-4110-96bc-7705e25dbc4e
Launched     net101-dns-quiz  {}
Initialized  net101-dns-quiz  {}
Passed       net101-dns-quiz  {"score":{"scaled":1,"raw":3,"min":0,"max":3},"success":true,"duration":"PT38S"}
Completed    net101-dns-quiz  {"completion":true,"duration":"PT39S"}
Terminated   net101-dns-quiz  {"duration":"PT40S"}
Satisfied    net101           {}

والصورة التالية تبين الجلسة نفسها، ومن يرسل كل جملة:

مخطط جلسة cmi5 واحدة: كل الجمل تحمل رقم التسجيل registration ورقم الجلسة sessionid نفسهما، في مسار ال LMS جملة launched مع masteryscore 0.7، ثم في مسار الوحدة AU جمل initialized بداية الجلسة، وpassed بدرجة scaled 1.0 أكبر من 0.7، وcompleted رأى كل المحتوى، وterminated بمدة PT40S، ثم في مسار ال LMS جملة satisfied للمقرر كله، لأن passed وcompleted تحملان تصنيف moveon والقاعدة CompletedAndPassed
ال LMS يفتح الجلسة ويغلق المقرر، والوحدة ترسل ما بينهما بترتيب ثابت

في السكربت أعلاه لاحظ التالي:

  • رقم التسجيل registration: هو الذي يجمع الجمل الست، وبه استعلمنا عنها من ال LRS بالمعامل ?registration=، وفي SCORM لا يوجد ما يقابله خارج قاعدة بيانات ال LMS.
  • الدرجة scaled: قيمة بين -1 و1، وcmi5 يقارنها بقيمة masteryscore التي أرسلها ال LMS في جملة launched، فالوحدة لا تقرر نسبة النجاح وحدها كما فعلت حزمة SCORM.
  • التصنيف moveon: على جملتي passed وcompleted فقط، وهو ما يقرأه ال LMS ليقرر أن الوحدة استوفت شرطها، فيرسل satisfied.

والصورة التالية لمتصفح البيانات في واجهة SQL LRS بعد الإرسال، وفيها جلستان أرسلناهما بالسكربت نفسه:

متصفح البيانات Data Browser في SQL LRS: بيانات الدخول moodle-key، والاستعلام الحالي /xapi/statements?limit=10، وجدول فيه رقم الجملة والفاعل خالد الطالب والفعل والمفعول، والصفوف من الأحدث Satisfied للمقرر net101 ثم Terminated وCompleted وPassed وInitialized وLaunched لاختبار DNS القصير، ثم جلسة سابقة بالأفعال نفسها
جمل جلسة cmi5 في متصفح البيانات، من الأحدث إلى الأقدم

ماذا يحدث إذا أرسلت الوحدة درجة خاطئة؟

من الأخطاء المعروفة في المحتوى أن يرسل الدرجة 67 في الحقل scaled بدلاً من 0.67، فجربنا ذلك بالملف bad.json:

{"actor": {"objectType": "Agent", "account": {"homePage": "https://lms.example.com", "name": "student1"}},
 "verb": {"id": "http://adlnet.gov/expapi/verbs/passed", "display": {"en-US": "Passed"}},
 "object": {"objectType": "Activity", "id": "https://lms.example.com/cmi5/au/net101-dns-quiz"},
 "result": {"score": {"scaled": 67}, "success": true}}

ورد ال LRS كان كما يلي، وهذا أوله:

400 {"error":{"message":"Invalid Statement Data", ... "spec-error":"67.0 - failed: (<= % 1.0) in: [:statement\/result :result\/score :score\/scaled] ...

رفض ال LRS الجملة بالرمز 400، والرسالة تحدد المكان بالضبط: result.score.scaled يجب أن يكون 1.0 أو أقل. وهذا الفرق عن خطأ الترجمة في القسم السابق مهم: الخطأ في صيغة الجملة يرفضه ال LRS فتراه، والخطأ في معناها يقبله ولا تراه إلا إذا قارنت.

ماذا تطلب من مزود المحتوى؟

الموقفالمعيار المناسبوالسبب
وحدة داخل مقرر في Moodle، تحتاج درجة ونجاحاً فقطSCORM 1.2Moodle يدعمه ويجتاز اختباراته، ولا يدعم SCORM 2004
منصة أخرى تدعم SCORM 2004 وتحتاج تسلسلاً بين الوحدات SequencingSCORM 2004بعد التأكد من أن ال LMS نفسه يدعمه، وليس فقط يقبل رفع الحزمة
تعلم خارج المتصفح: تطبيق جوال، محاكاة، معمل، أو بيانات من عدة أنظمةxAPI مع LRSلأنه لا يشترط LMS، ولكن اتفق مع المزود على الأفعال قبل البدء
محتوى يطلقه ال LMS وتريد بيانات xAPI غنية بقواعد ثابتةcmi5إذا كان ال LMS يدعمه، وإلا فلن يجد المحتوى من يطلقه
تحليلات تعلم من Moodle نفسهإضافة Logstore xAPI مع LRSبعد مقارنة عينة من الجمل مع بيانات Moodle كما فعلنا

وإذا كان المحتوى تفاعلياً تصنعه بنفسك داخل Moodle، فقد يكون H5P أبسط من كل ما سبق، لأنه يعمل داخل المنصة ويرسل نتائجه إلى سجل الدرجات مباشرة.

أخطاء شائعة

  • طلب SCORM 2004 لمنصة Moodle: الحزمة ترفع وتعمل أحياناً، ولكن التوثيق يقول إنه غير مدعوم، فلا تعتمد عليه في الدرجات.
  • الملف imsmanifest.xml داخل مجلد في الملف المضغوط: يجب أن يكون في الجذر.
  • الثقة في ترجمة xAPI دون مقارنة: كما رأينا، failed وصلت completed دون أي خطأ.
  • الإرسال اللحظي من ال LMS إلى ال LRS في الإنتاج: اجعله على دفعات، حتى لا يتأثر الطلاب إذا بطء ال LRS.
  • ترك مفتاح ال LRS الافتراضي: الواجهة نفسها تحذرك منه.

تنظيف المختبر

docker compose down -v

ثم احذف ال Docker Images التي حملتها إذا لم تعد تحتاجها، بأمر docker image rm واسمها الكامل.

تدريب عملي

  • أضف إلى الحزمة إرسال cmi.interactions.0.id وcmi.interactions.0.student_response لكل سؤال، ثم افتح تقرير التفاعلات في Moodle.
  • أرسل جلسة cmi5 فيها failed بعد passed، وتأكد هل يرفضها ال LRS أم يقبلها، ثم فكر: من المسؤول عن فرض قواعد cmi5، ال LRS أم ال LMS؟

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • SCORM يعمل داخل المتصفح وال LMS بكائن JavaScript، وMoodle يدعم 1.2 ولا يدعم 2004، وكل ما يحفظه هو قيم cmi التي ترسلها الحزمة.
  • xAPI جمل JSON يرسلها أي برنامج إلى LRS، وSQL LRS خيار مفتوح المصدر يقبل الإصدارين 1.0.3 و2.0.
  • إضافة Logstore xAPI 5.1.0 تدعم Moodle حتى 5.2، وفي تجربتنا ترجمت failed وpassed إلى completed بسبب تمرير رقم النشاط مكان رقم الوحدة.
  • cmi5 يضع فوق xAPI قواعد للإطلاق والترتيب والنجاح، ولكنه يحتاج LMS يطبقه.
  • ال LRS يرفض الجملة الخاطئة في صيغتها، ولا يعرف الجملة الخاطئة في معناها.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال وتجربته على Moodle 5.2.4 مع Logstore xAPI 5.1.0 وSQL LRS 0.9.9، وحزمة SCORM 1.2 مكتوبة للمقال.
Could not load content
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.