> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# تتبع تقدم المتعلم Learner Tracking من SCORM إلى xAPI
- URL: https://arabroot.io/articles/معايير-محتوى-التعلم-scorm-ام-xapi-ام-cmi5/
- Published: 2026-10-14T12:00:00.000Z
- Updated: 2026-10-25T08:50:24.000Z
- Description: حزمة SCORM حقيقية في Moodle، ثم جمل xAPI في LRS مفتوح المصدر، ثم جلسة cmi5، لتعرف أي معيار تطلبه من مزود المحتوى.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, التعليم والبحث العلمي, تطبيقات لفريقك, الاستضافة الذاتية, Moodle

تشتري الجامعة مقرراً جاهزاً من مزود محتوى، أو تطلب من فريق التصميم التعليمي أن يبني وحدة تفاعلية في أداة مثل Articulate، فيأتي السؤال الأول من المزود: هل تريدونه SCORM 1.2 أم SCORM 2004 أم xAPI أم cmi5؟ والإجابة الخاطئة تعني أن الحزمة تعمل في المتصفح ولكن الدرجة لا تصل إلى سجل الدرجات، أو تصل ناقصة. في مقال [أنظمة إدارة التعلم LMS](https://arabroot.io/articles/%D8%A7%D9%86%D8%B8%D9%85%D8%A9-%D8%A7%D8%AF%D8%A7%D8%B1%D8%A9-%D8%A7%D9%84%D8%AA%D8%B9%D9%84%D9%85-lms-%D9%85%D8%A7%D8%B0%D8%A7-%D8%AA%D8%AD%D8%AA%D8%A7%D8%AC-%D9%85%D9%86%D9%87%D8%A7-%D9%85%D8%A4%D8%B3%D8%B3%D8%AA%D9%83/) ذكرنا أن المنصة يجب أن تستقبل المحتوى من الخارج، وفي هذا المقال سوف نأخذ هذا الجزء بالتجربة، وسوف نتناول:

- ما الذي يحل كل معيار من الثلاثة، وكيف تنتقل بيانات المتعلم في كل منها.
- حزمة 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](https://arabroot.io/content/images/2026/10/scorm-xapi-cmi5-01-three-models.webp)

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، و[مستودع المواصفة](https://github.com/adlnet/xAPI-Spec?ref=arabroot.io) يذكر أن الإصدار الحالي هو 2.0 الذي أصبح معياراً من IEEE.
- **cmi5:** ليس معياراً منفصلاً عن xAPI، وإنما ملف تعريف Profile فوقه، و[مواصفته](https://github.com/AICC/CMI-5%5FSpec%5FCurrent/blob/quartz/cmi5%5Fspec.md?ref=arabroot.io) صدرت بإصدار Quartz في 1 يونيو 2016، وتعيد ما فقده xAPI من SCORM: ال LMS يطلق المحتوى، ويعطيه عنوان ال LRS وتوكن مؤقت، والمحتوى يرسل جملاً محددة بترتيب محدد.

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

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

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

```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:

```html
<!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](https://arabroot.io/articles/%D9%85%D8%A7-%D9%87%D9%88-scorm-%D9%88%D9%83%D9%8A%D9%81-%D9%8A%D8%AA%D9%88%D8%A7%D8%B5%D9%84-%D8%A7%D9%84%D9%85%D8%AD%D8%AA%D9%88%D9%89-%D9%85%D8%B9-lms/)، والذي يهمنا هنا للمقارنة نقطتان: الدالة `findAPI` تصعد في النوافذ الأم حتى تجد الكائن `API`، لذلك لا تعمل الحزمة خارج ال LMS، فهي تعرض النتيجة ولكنها لا تجد من يرسلها إليه، والحزمة نفسها هي التي تقرر النجاح بمقارنة الدرجة بالرقم 70، وهذا ما سوف يتغير في cmi5.

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

## الحزمة في Moodle

قبل أن نرفع الحزمة لاحظ ما يقوله [سؤال وجواب SCORM في توثيق Moodle](https://docs.moodle.org/503/en/SCORM%5FFAQ?ref=arabroot.io): يدعم Moodle الإصدار SCORM 1.2 ويجتاز اختبارات المطابقة له، ويدعم AICC، ولا يدعم SCORM 2004، والتطوير الأصلي له متوقف، والحل المقترح لمن يحتاجه إضافة تجارية تربط بخدمة SCORM Cloud. لذلك إذا كانت منصتك Moodle فاطلب من المزود SCORM 1.2 صراحة.

المختبر هو نفسه الذي شرحناه في دليل [تثبيت Moodle](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-moodle-%D9%85%D9%86%D8%B5%D8%A9-%D8%A7%D9%84%D8%AA%D8%B9%D9%84%D9%85-%D8%B9%D9%84%D9%89-%D8%B3%D9%8A%D8%B1%D9%81%D8%B1%D9%83/) مع ثلاثة فروق: الإصدار 5.2.4 وليس 5.3، والسبب سوف يأتي في قسم xAPI، وContainer إضافي لل LRS، والمنفذ 31080\. وهذا ملف `compose.yaml` الكامل:

```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، وزر سلم الإجابات](https://arabroot.io/content/images/2026/10/scorm-xapi-cmi5-02-scorm-player.webp)

الحزمة داخل مشغل Moodle، والطالب أخطأ عمداً في السؤال الثالث

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

```bash
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;"
```

```text
 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](https://arabroot.io/content/images/2026/10/scorm-xapi-cmi5-03-scorm-report.webp)

تقرير النشاط: محاولتان، 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 ثواني](https://arabroot.io/content/images/2026/10/scorm-xapi-cmi5-04-scorm-tracks.webp)

تفاصيل المحاولة الأولى: كل ما أرسلته الحزمة عبر LMSSetValue

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

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

جملة xAPI تشبه جملة عادية: فاعل Actor وفعل Verb ومفعول Object، ونتيجة Result إن وجدت، مثل «خالد أكمل اختبار DNS بدرجة 67». ويحتاج xAPI إلى مكان يستقبل هذه الجمل ويخزنها ويسمح بالبحث فيها، وهو ال LRS، واخترنا له [SQL LRS](https://github.com/yetanalytics/lrsql?ref=arabroot.io) من Yet Analytics، وهو مفتوح المصدر بترخيص Apache-2.0، يعمل على SQLite أو PostgreSQL أو MariaDB، والإصدار 0.9.0 منه أضاف دعم xAPI 2.0\. وأول ما نفعله أن نسأله عن الإصدارات التي يقبلها:

```bash
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
```

```text
{"version":["2.0.0","1.0.3"]}
```

⚠️

واجهة الإدارة في SQL LRS تعرض بعد الدخول التحذير «Seed credentials should be deleted!»، والسبب أن الحساب والمفتاح اللذين وضعناهما في متغيرات البيئة للمختبر معروفان لكل من يقرأ ملف Compose، فأنشئ في الإنتاج حساباً ومفتاحاً جديدين من الواجهة واحذف الأولين.

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

Moodle نفسه لا يرسل xAPI إلى LRS خارجي، و[التوثيق](https://docs.moodle.org/503/en/SCORM%5FFAQ?ref=arabroot.io) يحيل في ذلك إلى إضافات من طرف ثالث، وأشهرها [Logstore xAPI](https://github.com/xAPI-vle/moodle-logstore%5Fxapi?ref=arabroot.io)، وهي مخزن سجلات Log Store يترجم أحداث Moodle إلى جمل xAPI. وآخر إصدار منها 5.1.0، و[ملاحظات الإصدار](https://github.com/xAPI-vle/moodle-logstore%5Fxapi/releases/tag/v5.1.0?ref=arabroot.io) تقول إنه يدعم Moodle من 4.5 إلى 5.2، ولهذا السبب جربنا على 5.2.4 وليس 5.3\. فككنا الإضافة في `public/admin/tool/log/store/xapi`، وثبتنا Moodle، ثم فعلناها وضبطناها من سطر الأوامر:

```bash
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 الذي ولد كل جملة:

```text
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 لطوله:

```json
{
  "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`، وتوقيعها كما يلي:

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

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

```php
    $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 ووحدة متوافقان، ثم نقرؤها. والسكربت التالي يفعل ذلك:

```javascript
// 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 || {})}`);
```

```bash
docker run --rm --network edu5-lms_default -m 256m -v "$PWD":/app:ro -w /app node:24-alpine node cmi5-session.mjs
```

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

```text
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](https://arabroot.io/content/images/2026/10/scorm-xapi-cmi5-06-cmi5-session.webp)

ال 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 القصير، ثم جلسة سابقة بالأفعال نفسها](https://arabroot.io/content/images/2026/10/scorm-xapi-cmi5-05-lrs-browser.webp)

جمل جلسة cmi5 في متصفح البيانات، من الأحدث إلى الأقدم

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

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

```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 كان كما يلي، وهذا أوله:

```text
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.2                  | Moodle يدعمه ويجتاز اختباراته، ولا يدعم SCORM 2004           |
| منصة أخرى تدعم SCORM 2004 وتحتاج تسلسلاً بين الوحدات Sequencing     | SCORM 2004                 | بعد التأكد من أن ال LMS نفسه يدعمه، وليس فقط يقبل رفع الحزمة |
| تعلم خارج المتصفح: تطبيق جوال، محاكاة، معمل، أو بيانات من عدة أنظمة | xAPI مع LRS                | لأنه لا يشترط LMS، ولكن اتفق مع المزود على الأفعال قبل البدء |
| محتوى يطلقه ال LMS وتريد بيانات xAPI غنية بقواعد ثابتة              | cmi5                       | إذا كان ال LMS يدعمه، وإلا فلن يجد المحتوى من يطلقه          |
| تحليلات تعلم من Moodle نفسه                                         | إضافة Logstore xAPI مع LRS | بعد مقارنة عينة من الجمل مع بيانات Moodle كما فعلنا          |

وإذا كان المحتوى تفاعلياً تصنعه بنفسك داخل Moodle، فقد يكون [H5P](https://arabroot.io/articles/%D9%85%D8%AD%D8%AA%D9%88%D9%89-%D8%AA%D9%81%D8%A7%D8%B9%D9%84%D9%8A-%D9%82%D8%A7%D8%A8%D9%84-%D9%84%D8%A7%D8%B9%D8%A7%D8%AF%D8%A9-%D8%A7%D9%84%D8%A7%D8%B3%D8%AA%D8%AE%D8%AF%D8%A7%D9%85-%D9%85%D8%B9-h5p/) أبسط من كل ما سبق، لأنه يعمل داخل المنصة ويرسل نتائجه إلى سجل الدرجات مباشرة.

## أخطاء شائعة

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

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

```bash
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 مكتوبة للمقال.