From 2707c22fcea91395741dfcd9aef3fbefbdd03218 Mon Sep 17 00:00:00 2001 From: Asterios Raptis Date: Mon, 31 Aug 2026 11:35:06 +0200 Subject: [PATCH] feat(schema): add Exercise.explanation for a post-answer "why" (idea 5) hint is shown on demand before/during answering, examples are worked examples shown before answering that must not spoil it - neither fits "explain the French word-order rule after the learner answers". Exercise gains an optional explanation (Markdown, max 1000 chars, mirrors hint's shape), not restricted to any exercise type. x-schema-version 1.12 -> 1.13 in both schemas (lockstep). Additive: content without the new field validates unchanged. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 14 ++++++ README.md | 2 +- docs/blog/de/one-source-many-outputs.md | 2 +- .../de/schema-first-content-engineering.md | 4 +- docs/blog/one-source-many-outputs.md | 2 +- docs/blog/schema-first-content-engineering.md | 4 +- docs/concepts.md | 2 +- docs/lesson-format.md | 19 +++++++- docs/schema-diagrams.md | 2 +- schema/content-manifest.schema.json | 2 +- schema/lesson.schema.json | 16 ++++++- schema/quality-rules.json | 2 +- .../content-manifest.schema.json | 2 +- .../schema-baseline/lesson.schema.json | 16 ++++++- src/schema-baseline.test.ts | 2 +- src/types/lesson-schema.generated.ts | 5 ++ src/validate.test.ts | 48 +++++++++++++++++++ 17 files changed, 128 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8b7fe8..f060e1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ All notable changes to `learn-content-engine`. The format is inspired by ## [Unreleased] +### `explanation`: post-answer "why" on any exercise (idea 5) + +A fifth language-learning exercise idea surfaced a gap the existing "extra +text" fields do not cover: `hint` is shown on demand before/during answering, +`examples` are worked examples shown before answering that must not spoil it, +and neither fits "explain the French word-order rule AFTER the learner +answers". `Exercise` gains an optional `explanation` (Markdown, max 1000 +chars, mirrors `hint`'s shape) - not restricted to any exercise type, like +`hint` itself. + +`x-schema-version` `1.12` -> `1.13` in both schemas (lesson + content-manifest +move in lockstep, established convention). Additive: content without the new +field validates unchanged. + ## [0.22.0] - 2026-08-11 ### Element-level stable identity: pairs, blanks, options (engine#91 Phase 2) diff --git a/README.md b/README.md index 92003af..5f78834 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ network, storage, or UI code - you supply the bytes and keep fetch + persistence. The bundled, strict JSON-Schema makes it a self-contained **format reference**: you can author and validate lessons without the application the format originated in ([Adaptive Learner](https://github.com/astrapi69/adaptive-learner)). -Tracks the lesson schema, currently **v1.12**. +Tracks the lesson schema, currently **v1.13**. ## Install diff --git a/docs/blog/de/one-source-many-outputs.md b/docs/blog/de/one-source-many-outputs.md index 6d3bc28..f8ef2f1 100644 --- a/docs/blog/de/one-source-many-outputs.md +++ b/docs/blog/de/one-source-many-outputs.md @@ -43,7 +43,7 @@ Drei Dinge bleiben in dem, was Sie schreiben, bewusst getrennt: der **Inhalt** ( Sie müssen dieses JSON nicht von Hand schreiben. Die Referenz-App liefert einen **Lektions-Editor** mit, der dem Wort "einfach" entwachsen ist: der klassische vierstufige Assistent (Metadaten, Karten, Übungen, Speichern & Teilen), ein Buch-Pfad, der eingefügte oder hochgeladene Lehrbuchkapitel in Wissens-Lektionen verwandelt, ein Erweiterungs-Zweig, der selbst benotete Quizze und Diktate ohne JSON autorierbar macht, und ein Bearbeitungsmodus. All das erzeugt Lektionen in demselben kanonischen Schema und kann sogar direkt aus der App einen Pull Request gegen ein Content-Repository öffnen; [Teil 3 der Serie](create-a-lesson-in-the-app.md) geht jeden Pfad durch. Was darüber hinaus **geplant** ist, ist ein reicherer, auf Lehrende zugeschnittener Editor für die schwereren Fälle (größere Sets). Beides ändert das Modell nicht: Die Quelle bleibt der Vertrag, und jeder Editor (einfach oder reich) ist nur ein weiteres Werkzeug, das ihn liest und schreibt. -> **Additiv als Prinzip.** Das Schema ist versioniert (aktuell 1.12) und wächst nur additiv: Neue Felder sind optional, eine letztes Jahr geschriebene Lektion validiert also heute noch. Sie werden nie gezwungen, Inhalte zu migrieren, nur weil sich das Schema bewegt hat. +> **Additiv als Prinzip.** Das Schema ist versioniert (aktuell 1.13) und wächst nur additiv: Neue Felder sind optional, eine letztes Jahr geschriebene Lektion validiert also heute noch. Sie werden nie gezwungen, Inhalte zu migrieren, nur weil sich das Schema bewegt hat. ## Validierung ist Rechtschreibprüfung für Test-Logik diff --git a/docs/blog/de/schema-first-content-engineering.md b/docs/blog/de/schema-first-content-engineering.md index 3fab0bb..4b6b0fb 100644 --- a/docs/blog/de/schema-first-content-engineering.md +++ b/docs/blog/de/schema-first-content-engineering.md @@ -9,11 +9,11 @@ tags: [architecture, schema-design, typescript, content-engineering] *Wie `learn-content-engine` ein stabiles Kern-Schema behält und trotzdem Raum für pädagogische Erfindung lässt: durch eine harte Linie zwischen dem Vertrag, den es besitzt, und den Regeln, die seine Consumer besitzen.* -`learn-content-engine` · Schema aktuell v1.12 · framework-agnostisches TypeScript +`learn-content-engine` · Schema aktuell v1.13 · framework-agnostisches TypeScript ## Das Content-Schema-Dilemma -`learn-content-engine` ist eine framework-agnostische TypeScript-Bibliothek, die Lerninhalte parst und validiert: Sprachkurse zuallererst, wobei ein `domain`-Feld (seit Engine 0.20.0 ein kontrolliertes Vokabular aus bekannten Werten plus other) dieselbe Form auch andere Wissensgebiete tragen lässt (Technik-Kurse, Führerschein-Vorbereitung, Psychologie). Sie verwandelt Rohquellen (Lektions-JSON plus eine `manifest.yaml`) in eine kanonische interne Form, und sie ist die einzige Quelle der Wahrheit für das Lektions-Schema, aktuell Version 1.12. +`learn-content-engine` ist eine framework-agnostische TypeScript-Bibliothek, die Lerninhalte parst und validiert: Sprachkurse zuallererst, wobei ein `domain`-Feld (seit Engine 0.20.0 ein kontrolliertes Vokabular aus bekannten Werten plus other) dieselbe Form auch andere Wissensgebiete tragen lässt (Technik-Kurse, Führerschein-Vorbereitung, Psychologie). Sie verwandelt Rohquellen (Lektions-JSON plus eine `manifest.yaml`) in eine kanonische interne Form, und sie ist die einzige Quelle der Wahrheit für das Lektions-Schema, aktuell Version 1.13. Der Kern ist bewusst klein. Kein Rendering, keine Persistenz, kein Netzwerk; die einzige Laufzeit-Abhängigkeit ist ein YAML-Parser. Was er bietet, ist reine Validierung und Transformation. Dieser Minimalismus ist der Punkt, und er erzwingt eine harte Frage: *Wie entwickelt man ein Content-Schema weiter, ohne jeden Consumer zu brechen, der davon abhängt?* diff --git a/docs/blog/one-source-many-outputs.md b/docs/blog/one-source-many-outputs.md index 02c42d4..518633e 100644 --- a/docs/blog/one-source-many-outputs.md +++ b/docs/blog/one-source-many-outputs.md @@ -43,7 +43,7 @@ Three things stay deliberately separate in what you write: the **content** (the You don't have to write that JSON by hand. The reference app ships a **lesson creator** that has outgrown the word "simple": the classic four-step wizard (metadata, cards, exercises, save & share), a book path that turns pasted or uploaded textbook chapters into knowledge lessons, an extension branch that makes even graded quizzes and dictation authorable without JSON, and an edit mode. All of it produces lessons in this same canonical schema and can even open a pull request against a content repository directly from the app; part 3 of the series walks through every path. What is **planned** beyond it is a richer, teacher-facing editor for the heavier cases (larger sets). Neither changes the model: the source stays the contract, and every editor (simple or rich) is just one more tool that reads and writes it. -> **Additive by policy.** The schema is versioned (currently 1.12) and only grows additively: new fields are optional, so a lesson written last year still validates today. You are never forced to migrate content just because the schema moved. +> **Additive by policy.** The schema is versioned (currently 1.13) and only grows additively: new fields are optional, so a lesson written last year still validates today. You are never forced to migrate content just because the schema moved. ## Validation is spell-check for test logic diff --git a/docs/blog/schema-first-content-engineering.md b/docs/blog/schema-first-content-engineering.md index 4af3472..c479df0 100644 --- a/docs/blog/schema-first-content-engineering.md +++ b/docs/blog/schema-first-content-engineering.md @@ -9,11 +9,11 @@ tags: [architecture, schema-design, typescript, content-engineering] *How `learn-content-engine` keeps a stable core schema and still leaves room for pedagogical invention: by drawing a hard line between the contract it owns and the rules its consumers own.* -`learn-content-engine` · schema currently v1.12 · framework-agnostic TypeScript +`learn-content-engine` · schema currently v1.13 · framework-agnostic TypeScript ## The content-schema dilemma -`learn-content-engine` is a framework-agnostic TypeScript library that parses and validates learning content: language courses foremost, though a `domain` field (a known-values-plus-other vocabulary since engine 0.20.0) lets the same shape carry other knowledge domains (tech courses, driving-test prep, psychology). It turns raw sources (lesson JSON plus a `manifest.yaml`) into a canonical internal shape, and it is the single source of truth for the lesson schema, currently version 1.12. +`learn-content-engine` is a framework-agnostic TypeScript library that parses and validates learning content: language courses foremost, though a `domain` field (a known-values-plus-other vocabulary since engine 0.20.0) lets the same shape carry other knowledge domains (tech courses, driving-test prep, psychology). It turns raw sources (lesson JSON plus a `manifest.yaml`) into a canonical internal shape, and it is the single source of truth for the lesson schema, currently version 1.13. The core is deliberately small. No rendering, no persistence, no networking; its only runtime dependency is a YAML parser. What it offers is pure validation and transformation. That minimalism is the point, and it forces one hard question: *how do you evolve a content schema without breaking every consumer that depends on it?* diff --git a/docs/concepts.md b/docs/concepts.md index 4384d8f..e3b839b 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -77,7 +77,7 @@ inherited by the set's lessons unless a lesson declares its own. ## Schema-version policy (additive) -The lesson schema is versioned (`x-schema-version`, currently `1.12`) and evolves +The lesson schema is versioned (`x-schema-version`, currently `1.13`) and evolves **additively**: new fields are optional, so **older content stays valid under a newer schema**. For example, v1.5 added the inline `examples` field; a v1.4 lesson without `examples` validates unchanged under v1.5. v1.6 added the native diff --git a/docs/lesson-format.md b/docs/lesson-format.md index 8949c19..5956b1e 100644 --- a/docs/lesson-format.md +++ b/docs/lesson-format.md @@ -237,9 +237,26 @@ and `prompt`. `type` is one of `matching`, `picture_choice`, `free_text`, for-type combinations are rejected (see [validation.md](validation.md)). Common optional exercise fields: `card_ids` (the cards drilled; each must exist), -`distractors`, `hint`, `direction`, `examples`, and `stable_id` (schema v1.9, +`distractors`, `hint`, `direction`, `examples`, `explanation` (schema v1.13, +see [Explanation](#explanation-post-answer)), and `stable_id` (schema v1.9, see [Stable identity](#stable-identity-stable_id)). +### Explanation (post-answer) + +`explanation` (string \| null, max 1000 chars, schema v1.13) is Markdown +explaining WHY the answer/grammar is what it is - a French word-order rule, a +grammatical case, a spelling exception. Timing is what distinguishes it from +the other three "extra text" fields: + +| Field | Shown | Purpose | +|---|---|---| +| `examples` | Before answering | Worked examples, must not spoil the answer. | +| `hint` | On demand, before/during answering | A nudge behind a "Need a hint?" button. | +| `explanation` | After answering (correct or not) | The reasoning behind the answer. | + +Not restricted to any exercise type; an author uses it wherever the "why" adds +value and omits it everywhere else. + ### matching Match left items to right items. Requires a non-empty `pairs` list of diff --git a/docs/schema-diagrams.md b/docs/schema-diagrams.md index 362d0e8..4b5054c 100644 --- a/docs/schema-diagrams.md +++ b/docs/schema-diagrams.md @@ -26,7 +26,7 @@ false ordering sentence survived in this repo for weeks. - + ### 1. Content structure (generated) diff --git a/schema/content-manifest.schema.json b/schema/content-manifest.schema.json index b3bb753..60f813c 100644 --- a/schema/content-manifest.schema.json +++ b/schema/content-manifest.schema.json @@ -380,5 +380,5 @@ ], "title": "ContentManifest", "type": "object", - "x-schema-version": "1.12" + "x-schema-version": "1.13" } diff --git a/schema/lesson.schema.json b/schema/lesson.schema.json index f232570..7951775 100644 --- a/schema/lesson.schema.json +++ b/schema/lesson.schema.json @@ -409,6 +409,20 @@ "description": "Optional inline worked examples shown BEFORE the answer controls, to help the learner understand the task (schema v1.5, additive). Each is plain text or a syntax-highlighted code snippet (see ``InlineExample.language``). Author responsibility not to spoil the answer. Independent of the per-type fields; absent on exercises that need no example.", "title": "Examples" }, + "explanation": { + "anyOf": [ + { + "maxLength": 1000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional Markdown explanation of WHY the answer/grammar is what it is (e.g. word order, a grammatical case), shown AFTER the learner answers - regardless of correct or incorrect - unlike ``hint`` (on demand, before/during answering) and ``examples`` (worked examples shown before answering, must not spoil it). Not restricted to any exercise type. Additive; schema_version 1.13.", + "title": "Explanation" + }, "ext_payload": { "additionalProperties": true, "description": "Opaque per-exercise payload for an ``ext:`` extension type. The core engine does not interpret it; the registered extension validator does. Absent on core exercises.", @@ -1244,5 +1258,5 @@ ], "title": "Lesson", "type": "object", - "x-schema-version": "1.12" + "x-schema-version": "1.13" } diff --git a/schema/quality-rules.json b/schema/quality-rules.json index ef8e02f..bb6f88f 100644 --- a/schema/quality-rules.json +++ b/schema/quality-rules.json @@ -1,5 +1,5 @@ { - "$schema-version": "1.12", + "$schema-version": "1.13", "_comment": "GENERATED from scripts/generate_lesson_schema.py (EXP-039). Do not edit. Shared quality minimums for the content quality gate (app + content repo).", "rules": { "minExerciseTypes": 2, diff --git a/src/__fixtures__/schema-baseline/content-manifest.schema.json b/src/__fixtures__/schema-baseline/content-manifest.schema.json index b3bb753..60f813c 100644 --- a/src/__fixtures__/schema-baseline/content-manifest.schema.json +++ b/src/__fixtures__/schema-baseline/content-manifest.schema.json @@ -380,5 +380,5 @@ ], "title": "ContentManifest", "type": "object", - "x-schema-version": "1.12" + "x-schema-version": "1.13" } diff --git a/src/__fixtures__/schema-baseline/lesson.schema.json b/src/__fixtures__/schema-baseline/lesson.schema.json index f232570..7951775 100644 --- a/src/__fixtures__/schema-baseline/lesson.schema.json +++ b/src/__fixtures__/schema-baseline/lesson.schema.json @@ -409,6 +409,20 @@ "description": "Optional inline worked examples shown BEFORE the answer controls, to help the learner understand the task (schema v1.5, additive). Each is plain text or a syntax-highlighted code snippet (see ``InlineExample.language``). Author responsibility not to spoil the answer. Independent of the per-type fields; absent on exercises that need no example.", "title": "Examples" }, + "explanation": { + "anyOf": [ + { + "maxLength": 1000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional Markdown explanation of WHY the answer/grammar is what it is (e.g. word order, a grammatical case), shown AFTER the learner answers - regardless of correct or incorrect - unlike ``hint`` (on demand, before/during answering) and ``examples`` (worked examples shown before answering, must not spoil it). Not restricted to any exercise type. Additive; schema_version 1.13.", + "title": "Explanation" + }, "ext_payload": { "additionalProperties": true, "description": "Opaque per-exercise payload for an ``ext:`` extension type. The core engine does not interpret it; the registered extension validator does. Absent on core exercises.", @@ -1244,5 +1258,5 @@ ], "title": "Lesson", "type": "object", - "x-schema-version": "1.12" + "x-schema-version": "1.13" } diff --git a/src/schema-baseline.test.ts b/src/schema-baseline.test.ts index 88255e8..233acea 100644 --- a/src/schema-baseline.test.ts +++ b/src/schema-baseline.test.ts @@ -17,7 +17,7 @@ const NEW_ID_BASE = "https://astrapi69.github.io/learn-content-engine/schema"; /** The current, deliberately-set schema version. Bumping it is a conscious * release decision (new exercise type = minor); update it together with the * frozen baseline in the same commit. */ -const EXPECTED_SCHEMA_VERSION = "1.12"; +const EXPECTED_SCHEMA_VERSION = "1.13"; const readText = (relativePath: string): string => readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8"); diff --git a/src/types/lesson-schema.generated.ts b/src/types/lesson-schema.generated.ts index 9aa213f..5c9f0c0 100644 --- a/src/types/lesson-schema.generated.ts +++ b/src/types/lesson-schema.generated.ts @@ -215,6 +215,10 @@ export type Distractors = string[]; * Optional inline worked examples shown BEFORE the answer controls, to help the learner understand the task (schema v1.5, additive). Each is plain text or a syntax-highlighted code snippet (see ``InlineExample.language``). Author responsibility not to spoil the answer. Independent of the per-type fields; absent on exercises that need no example. */ export type Examples1 = InlineExample[] | null; +/** + * Optional Markdown explanation of WHY the answer/grammar is what it is (e.g. word order, a grammatical case), shown AFTER the learner answers - regardless of correct or incorrect - unlike ``hint`` (on demand, before/during answering) and ``examples`` (worked examples shown before answering, must not spoil it). Not restricted to any exercise type. Additive; schema_version 1.13. + */ +export type Explanation = string | null; /** * MATCHING: when true, the exercise derives its ``pairs`` from the referenced cards (left = card ``front``, right = card ``back``) instead of listing them explicitly, so a definition lives in one place. Requires non-empty ``card_ids`` and forbids an explicit ``pairs`` list. The engine resolves it to concrete ``pairs`` at parse time. Additive + optional; schema_version stays 1.5. */ @@ -520,6 +524,7 @@ export interface Exercise { direction?: Direction; distractors?: Distractors; examples?: Examples1; + explanation?: Explanation; /** * Opaque per-exercise payload for an ``ext:`` extension type. The core engine does not interpret it; the registered extension validator does. Absent on core exercises. */ diff --git a/src/validate.test.ts b/src/validate.test.ts index d423d3e..a58e8e1 100644 --- a/src/validate.test.ts +++ b/src/validate.test.ts @@ -425,6 +425,54 @@ describe("schema 1.12 — stable_id on pairs/blanks/options (engine#91 Phase 2)" }); }); +describe("schema 1.13 — explanation on exercises (idea 5: post-answer 'why')", () => { + const lessonWithExplanation = (explanation?: string | null) => ({ + id: "l1", + title: "Explanation", + steps: [ + { + id: "s1", + type: "exercise", + exercise: { + id: "e1", + type: "free_text", + prompt: "p", + accept: ["a"], + ...(explanation !== undefined ? { explanation } : {}), + }, + }, + ], + }); + + it("accepts an exercise with a Markdown explanation", () => { + const checked = validateLesson(lessonWithExplanation("Il s'agit du subjonctif car...")); + expect(checked.errors).toEqual([]); + expect(checked.valid).toBe(true); + }); + + it("stays optional: an exercise without explanation validates unchanged (pre-1.13 content)", () => { + expect(validateLesson(lessonWithExplanation()).valid).toBe(true); + }); + + it("accepts an explicit null (the documented default)", () => { + expect(validateLesson(lessonWithExplanation(null)).valid).toBe(true); + }); + + it("boundary: rejects an explanation longer than 1000 characters", () => { + expect(validateLesson(lessonWithExplanation("x".repeat(1001))).valid).toBe(false); + }); + + it("boundary: accepts an explanation at exactly 1000 characters", () => { + expect(validateLesson(lessonWithExplanation("x".repeat(1000))).valid).toBe(true); + }); + + it("is not restricted to one exercise type: a matching exercise may carry it too", () => { + const lesson = clone(conf("matching")); + exerciseOf(lesson).explanation = "Diese Zuordnung folgt der grammatischen Regel..."; + expect(validateLesson(lesson).valid).toBe(true); + }); +}); + describe("schema 1.9 — attribution and review_status on the set entry (engine#90/#94)", () => { const manifestWith = (setExtras: Record) => ({ schema_version: "1.2",