Chapitre 7.3 — State & Profils
⏱️ TL;DR — Les statements sont un journal immuable : ce qui s’est passé, pour toujours. Mais parfois tu veux juste mémoriser un état courant qu’on écrase — où en est l’apprenant dans le module, pour qu’il reprenne là où il s’était arrêté. C’est le State API (
activities/state) : un petit magasin clé-valeur indexé par(activité, agent, [registration], stateId). C’est l’équivalent moderne et sans limite étroite dususpend_datade SCORM. À côté, deux magasins de profils : Agent Profile (agents/profile) pour les préférences durables d’un apprenant (langue, options d’accessibilité), et Activity Profile (activities/profile) pour des données attachées à une activité. Règle : statement = événement figé (append-only) ; State/Profil = donnée mutable qu’on lit, écrase, supprime.
🎯 Objectifs
- Distinguer un statement (événement immuable) d’un State (état courant mutable).
- Utiliser le State API pour la reprise / le bookmarking d’un module.
- Situer Agent Profile (préférences durables) et Activity Profile.
- Choisir la bonne clé :
(activityId, agent, registration, stateId). - Savoir quand écrire un State plutôt qu’un statement (et l’inverse).
Statement vs State : deux mémoires différentes
Ne confonds jamais les deux. Ce sont deux besoins opposés.
| Statement | State | |
|---|---|---|
| Nature | Événement (« a terminé le module ») | État courant (« page 7 sur 12, réponses en cours ») |
| Mutabilité | Immuable (append-only) | Mutable (on écrase, on supprime) |
| On en garde… | tout l’historique | la dernière valeur |
| Ressource | statements | activities/state |
| Analogie SCORM | le statut / score remonté | cmi.suspend_data + lesson_location |
Autrement dit : tu traces un événement avec un statement ; tu sauvegardes une progression rechargeable avec un State. Sauvegarder « page 7 » à chaque changement de page en statements, ce serait polluer le journal de milliers d’événements sans intérêt historique. Sauvegarder une complétion en State, ce serait la perdre au prochain écrasement. Chacun son rôle.
Le State API : la reprise d’un module
Le State API est un magasin document (clé → valeur, la valeur pouvant être n’importe quoi : JSON, texte…). La clé est composée :
activityId— l’IRI de l’activité (le module),agent— l’apprenant (objet Agent JSON),registration— optionnel : l’UUID de la tentative, pour isoler la sauvegarde d’une inscription précise,stateId— un nom que tu choisis pour ce document (ex.bookmark,reponses-en-cours).
Écrire l’état (reprise)
PUT https://lrs.formacampus.fr/xapi/activities/state?activityId=https%3A%2F%2Fformacampus.fr%2Fxapi%2Factivities%2Fmodule-securite-numerique&agent=%7B%22mbox%22%3A%22mailto%3Aawa.diop%40ecole.fr%22%7D®istration=e8a1f0c2-77b0-4a2e-9c3d-2b1a0f9e8d7c&stateId=bookmark HTTP/1.1
Host: lrs.formacampus.fr
Authorization: Basic Zm9ybWFjYW1wdXM6czNjcjN0
X-Experience-API-Version: 1.0.3
Content-Type: application/json
{
"page": 7,
"totalPages": 12,
"reponses": { "q3": "b", "q4": ["a", "c"] },
"secondesEcoulees": 842
}Réponse : 204 No Content. La donnée est stockée sous cette clé.
Relire l’état au chargement suivant
GET https://lrs.formacampus.fr/xapi/activities/state?activityId=https%3A%2F%2Fformacampus.fr%2Fxapi%2Factivities%2Fmodule-securite-numerique&agent=%7B%22mbox%22%3A%22mailto%3Aawa.diop%40ecole.fr%22%7D®istration=e8a1f0c2-77b0-4a2e-9c3d-2b1a0f9e8d7c&stateId=bookmark HTTP/1.1
Host: lrs.formacampus.fr
Authorization: Basic Zm9ybWFjYW1wdXM6czNjcjN0
X-Experience-API-Version: 1.0.3Le LRS renvoie le document JSON tel quel (ou 404 s’il n’existe pas encore — cas d’une première ouverture).
En JavaScript : sauver et reprendre
function stateUrl({ activityId, agent, registration, stateId }) {
const qs = new URLSearchParams({
activityId,
agent: JSON.stringify(agent),
stateId,
});
if (registration) qs.set("registration", registration);
return `${LRS}/activities/state?${qs.toString()}`;
}
// Sauvegarder la progression (à chaque jalon : changement de page, réponse…).
async function saveProgress(key, data) {
await fetch(stateUrl(key), {
method: "PUT",
headers: {
"Content-Type": "application/json",
"Authorization": AUTH,
"X-Experience-API-Version": "1.0.3",
},
body: JSON.stringify(data),
});
}
// Reprendre au chargement : renvoie l'état, ou null si aucune sauvegarde.
async function loadProgress(key) {
const res = await fetch(stateUrl(key), {
headers: { "Authorization": AUTH, "X-Experience-API-Version": "1.0.3" },
});
if (res.status === 404) return null; // première ouverture
if (!res.ok) throw new Error(`LRS ${res.status}`);
return res.json();
}💡 Réflexe — Le State sert à reprendre, le statement à acter. À la fin d’un module : tu écris l’avancement en State au fil de l’eau (reprise possible à tout moment) et tu émets un statement
completedune fois à la complétion (fait historique). Les deux, pas l’un à la place de l’autre. Tu peux même effacer le State de reprise (DELETE) une fois le module terminé : il n’a plus d’utilité.
Supprimer et lister
DELETEsur la même URL efface ce document d’état (ex. purger la reprise après complétion).- Un
GETsuractivities/statesansstateId(mais avecactivityId+agent) renvoie la liste desstateIdexistants pour cette activité et cet agent.
⚠️ Piège — Confondre
registrationprésent et absent. Avecregistration, la sauvegarde est propre à une tentative : recommencer le cours (nouvelleregistration) repart de zéro. Sansregistration, l’état est partagé entre toutes les tentatives de l’apprenant sur cette activité. Choisis exprès : « reprise dans la tentative en cours » (avec) vs « préférence de lecture persistante toutes tentatives » (sans). Un mélange involontaire donne des reprises fantômes.
L’équivalent moderne de suspend_data
En SCORM (Partie 4), la reprise passe par cmi.suspend_data : une chaîne opaque, limitée à 4096 caractères en 1.2 (64000 en 2004), que le SCO sérialise comme il peut. Les auteurs SCORM se battent contre cette limite depuis vingt ans.
Le State API lève ces contraintes :
- Tu stockes du
JSONstructuré, pas une chaîne bricolée. - Il n’y a pas de limite étroite façon 4096 caractères (le LRS fixe ses propres bornes, généralement larges).
- Tu peux avoir plusieurs documents d’état (
stateIddifférents) pour une même activité. - Ça marche hors LMS (mobile, présentiel…), là où
suspend_datan’existe pas.
🔌 Côté intégration — En cmi5 (Partie 8), le LMS dépose lui-même un document State bien connu,
LMS.LaunchData, que l’AU lit au démarrage pour récupérer son contexte de lancement. C’est le même State API que celui décrit ici : le mécanisme de reprise et le mécanisme de lancement cmi5 partagent la même ressourceactivities/state. Comprendre le State maintenant, c’est déjà comprendre une brique de cmi5.
Les profils : préférences durables
Là où le State est lié à une activité, les profils stockent des données transverses.
Agent Profile — les préférences de l’apprenant
agents/profile est indexé par (agent, profileId). Idéal pour ce qui suit l’apprenant partout, indépendamment d’un module : langue préférée, options d’accessibilité, thème.
PUT https://lrs.formacampus.fr/xapi/agents/profile?agent=%7B%22mbox%22%3A%22mailto%3Aawa.diop%40ecole.fr%22%7D&profileId=preferences HTTP/1.1
Host: lrs.formacampus.fr
Authorization: Basic Zm9ybWFjYW1wdXM6czNjcjN0
X-Experience-API-Version: 1.0.3
Content-Type: application/json
{
"langue": "fr-FR",
"sousTitres": true,
"contrasteEleve": true,
"vitesseLecture": 1.25
}Au prochain démarrage de n’importe quel contenu, tu relis ce profil (GET même URL) et tu appliques les préférences. L’apprenant retrouve ses réglages sans les refaire.
Activity Profile — données attachées à une activité
activities/profile, indexé par (activityId, profileId), stocke des données sur l’activité elle-même (partagées entre apprenants) : un barème, une configuration, des métadonnées d’affichage. Moins courant, mais utile pour ne pas coder en dur des paramètres d’activité côté contenu.
Récapitulatif des trois magasins « document »
| Ressource | Clé | Pour… |
|---|---|---|
activities/state | (activityId, agent, [registration], stateId) | l’état d’un apprenant dans une activité (reprise) |
agents/profile | (agent, profileId) | les préférences durables d’un apprenant (transverses) |
activities/profile | (activityId, profileId) | des données sur l’activité (partagées) |
Les trois se manipulent de la même façon : PUT pour écrire/écraser, GET pour lire, DELETE pour effacer, POST pour une fusion partielle de JSON.
⚠️ Piège — Écraser au lieu de fusionner.
PUTremplace tout le document : si tu ne renvoies que{"sousTitres": false}, tu perdslangue,contrasteEleve… Pour ne modifier qu’un champ, soit tu relis-modifies-réécris le document complet enPUT, soit tu utilisesPOST(qui fusionne au niveau des clésJSONde premier niveau). Sache lequel des deux tu appelles.
💡 Réflexe — Gère la concurrence avec les en-têtes conditionnels. State et profils supportent l’
ETag: le LRS renvoie unETagauGET, et tu le renvoies enIf-Matchsur lePUT. Si deux onglets sauvegardent en même temps, le second reçoit un412 Precondition Failedau lieu d’écraser silencieusement le premier. Sur un contenu ouvert en double, ça évite les pertes de progression.
Quand State, quand statement ?
L’arbre de décision tient en une question : ai-je besoin de l’historique, ou juste de la dernière valeur ?
- « Awa a terminé le module » → statement (fait daté, on garde tout).
- « Awa en est à la page 7, réponses en cours » → State (on écrase, seule la dernière valeur compte).
- « Awa préfère les sous-titres et le contraste élevé » → Agent Profile (préférence durable, transverse).
🧭 Sur FormaCampus — Les modules web et l’appli mobile de FormaCampus partagent la reprise via le State API : l’apprenant commence un module au bureau, le LRS enregistre
stateId=bookmark(page + réponses en cours) ; il rouvre le soir sur mobile, l’appli fait unGET state, retrouve la page 7 et reprend. À la complétion, elle émet un statementcompletedet efface le State de reprise. Ses préférences d’accessibilité, elles, vivent dans un Agent Profile appliqué à tous ses contenus. Trois usages, trois ressources, zéro confusion.
📚 La spec — Le State API (
activities/state), l’Agent Profile (agents/profile) et l’Activity Profile (activities/profile) — leurs clés, les méthodesPUT/POST/GET/DELETE, la fusion viaPOST, la gestion de concurrence parETag/If-Match— sont définis dans la spec xAPI d’ADL (adlnet.gov). Le documentLMS.LaunchDatadéposé au lancement est spécifié côté cmi5 (Partie 8).
✏️ Exercices
Exercice 1 — State ou statement ? Classe chacun : (a) « l’apprenant a répondu correctement à la question 4 » à des fins de statistiques ; (b) « position actuelle dans la vidéo : 03:12, pour reprendre » ; (c) « langue d’interface préférée : fr-FR » ; (d) « l’apprenant a réussi l’évaluation finale ».
✅ Solution
(a) Statement — c’est un événement dont on veut l’historique statistique (verbe answered). (b) State (activities/state) — un curseur de reprise qu’on écrase en continu, aucun intérêt historique. (c) Agent Profile (agents/profile) — préférence durable, transverse aux activités. (d) Statement — fait d’apprentissage figé et daté (verbe passed/completed). Règle : besoin d’historique → statement ; dernière valeur courante → State/Profil ; lié à une activité → State ; transverse à l’apprenant → Agent Profile.
Exercice 2 — La reprise qui repart à zéro. Un module écrit sa progression en State avec registration, mais à chaque ouverture l’apprenant recommence au début alors que le PUT réussit. Le GET de reprise renvoie 404. Hypothèse la plus probable ?
✅ Solution
La clé de lecture ne correspond pas à la clé d’écriture. Le plus fréquent : une registration différente à chaque ouverture (une nouvelle est générée au lieu de réutiliser celle de la tentative en cours), donc le GET cherche sous une clé qui n’a jamais été écrite → 404. Autres pistes du même genre : activityId légèrement différent (une IRI qui varie), ou un agent identifié tantôt par mbox tantôt par account. La clé State est (activityId, agent, registration, stateId) : les quatre doivent être identiques en lecture et en écriture.
Exercice 3 — Fusion vs écrasement. L’apprenant a un Agent Profile { "langue": "fr-FR", "sousTitres": true, "contrasteEleve": true }. On veut juste passer sousTitres à false. Le dev fait PUT profileId=preferences avec { "sousTitres": false }. Résultat ? Comment bien faire ?
✅ Solution
PUT remplace tout le document : après l’appel, le profil ne contient plus que { "sousTitres": false } — langue et contrasteEleve sont perdus. Pour ne changer qu’un champ : soit relire le document, modifier sousTitres, et PUT le document complet ; soit utiliser POST sur la même clé avec { "sousTitres": false }, qui fusionne au niveau des clés de premier niveau et conserve le reste. (Idéalement avec ETag/If-Match pour éviter d’écraser une modif concurrente.)
🧠 Quiz de révision
1. Quelle est la différence fondamentale entre un statement et un State ?
Le statement est un événement immuable (append-only) dont on garde tout l’historique. Le State est un état courant mutable qu’on écrase et dont seule la dernière valeur compte. On trace un fait avec un statement ; on sauvegarde une progression rechargeable avec un State.
2. De quoi est composée la clé d’un document State ?
De (activityId, agent, [registration], stateId). Le registration est optionnel : présent, il lie l’état à une tentative précise ; absent, l’état est partagé entre toutes les tentatives de l’apprenant sur l’activité. Les quatre composantes doivent coïncider entre écriture et lecture.
3. En quoi le State API est-il l’équivalent moderne de suspend_data ?
suspend_data ?Il sert à la reprise (sauvegarder l’avancement pour repartir où on s’était arrêté), comme cmi.suspend_data en SCORM — mais sans la limite étroite (4096/64000 caractères), avec du JSON structuré, plusieurs documents possibles, et un fonctionnement hors LMS.
4. Où stocke-t-on les préférences d’accessibilité durables d’un apprenant ?
Dans l’Agent Profile (agents/profile), indexé par (agent, profileId). Ces préférences sont transverses aux activités et suivent l’apprenant partout, contrairement au State qui est lié à une activité précise.
5. PUT sur un profil avec un seul champ : que devient le reste ?
PUT sur un profil avec un seul champ : que devient le reste ?Il est perdu : PUT remplace tout le document. Pour ne modifier qu’un champ sans perdre les autres, on PUT le document complet (relu puis modifié) ou on utilise POST, qui fusionne au niveau des clés de premier niveau. Idéalement avec ETag/If-Match contre les écritures concurrentes.
Chapitre suivant : 7.4 — Tracer hors du LMS — le vrai super-pouvoir de xAPI : capturer l’apprentissage là où SCORM ne va pas — mobile, atelier, terrain, hors-ligne.