From fe078b422aa7fb5ebf6273400fd8bc5a63f34304 Mon Sep 17 00:00:00 2001 From: Asterios Raptis Date: Mon, 31 Aug 2026 11:30:43 +0200 Subject: [PATCH] feat(examples): add ext:ref-audio-choice, ext:ref-audio-tiles, ext:ref-speak-and-record Three language-learning exercise ideas, audited against the existing audio/image reference extensions (engine#68) and found missing: a gapped sentence with audio options, a spoken sentence built as a translation from word tiles, and an ungraded speak-and-record activity. All three follow the established self-contained ext_payload shape; ref-speak-and-record is the first reference extension with no grade function, since a recording has nothing to check it against. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 12 ++ docs/extensions.md | 182 ++++++++++++++++++ .../audio-choice-extension.ts | 135 +++++++++++++ .../ext-ref-audio-choice/audio-choice.test.ts | 175 +++++++++++++++++ .../audio-tiles-extension.ts | 143 ++++++++++++++ .../ext-ref-audio-tiles/audio-tiles.test.ts | 164 ++++++++++++++++ .../speak-and-record-extension.ts | 88 +++++++++ .../speak-and-record.test.ts | 109 +++++++++++ 8 files changed, 1008 insertions(+) create mode 100644 src/examples/ext-ref-audio-choice/audio-choice-extension.ts create mode 100644 src/examples/ext-ref-audio-choice/audio-choice.test.ts create mode 100644 src/examples/ext-ref-audio-tiles/audio-tiles-extension.ts create mode 100644 src/examples/ext-ref-audio-tiles/audio-tiles.test.ts create mode 100644 src/examples/ext-ref-speak-and-record/speak-and-record-extension.ts create mode 100644 src/examples/ext-ref-speak-and-record/speak-and-record.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f8b7fe8..3f02652 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ All notable changes to `learn-content-engine`. The format is inspired by ## [Unreleased] +### Three new reference extensions: audio-choice, audio-tiles, speak-and-record + +Three language-learning exercise ideas surfaced as gaps against the existing +reference extensions (engine#68's audio/image-stimulus precedent): a gapped +sentence with audio options (`ext:ref-audio-choice`), a spoken sentence built +as a translation from word tiles (`ext:ref-audio-tiles`), and an ungraded +speak-and-record activity (`ext:ref-speak-and-record`). All three follow the +established self-contained-`ext_payload` shape (no core-schema change, no +card reference); `ref-speak-and-record` is the first reference extension with +no grade function, since a recording has nothing to check it against. See +`docs/extensions.md` for the payload rules and reference lessons. + ## [0.22.0] - 2026-08-11 ### Element-level stable identity: pairs, blanks, options (engine#91 Phase 2) diff --git a/docs/extensions.md b/docs/extensions.md index de49c14..c96a2b1 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -507,6 +507,188 @@ view there) and grades the typed answer against EVERY `accept` entry (trim + case-fold; a production consumer would reuse its free-text matcher for typo tolerance). +## Example extension: `ext:ref-audio-choice` + +`src/examples/ext-ref-audio-choice/` works out the audio multiple-choice case: +a gapped sentence with N audio options, one of which fills the gap ("listen to +the words, pick the one that fits"). The flat core schema has no audio-option +choice type: `images` is `picture_choice`'s visual twin (an exactly-one-correct +OPTION list), `multiple_choice`'s options carry text only, and `free_text` +carries no media. So instead of a core-schema change it is modelled as a +SINGLE ext exercise whose `ext_payload` carries the gapped sentence plus the +audio options. + +The payload is deliberately SELF-CONTAINED: no card reference, everything the +consumer needs sits in `ext_payload`. The engine validates only the SHAPE of +`sentence` and each option's `audio`, plus an exactly-one-correct contract +mirrored from core `picture_choice`'s own `E-PIC-ONE-CORRECT` rule. There is +deliberately NO label/text field on an option: a visible word next to its +audio would spoil a listening exercise the same way alt-text would spoil an +image one. + +Payload rules (engine half `refAudioChoiceExtension`): + +| Id | Rule | +|---|---| +| `E-EXT-REFAUDIOCHOICE-SHAPE` | `ext_payload` must carry `sentence` (string) and `options` (at least 2 entries, each `{audio: string, is_correct?: "true"}`). | +| `E-EXT-REFAUDIOCHOICE-SENTENCE` | `sentence` is non-empty and contains the gap marker `___`. | +| `E-EXT-REFAUDIOCHOICE-AUDIO` | Every option's `audio` is non-empty. | +| `E-EXT-REFAUDIOCHOICE-CORRECT` | Exactly one option is marked `is_correct: "true"`. | + +A reference lesson on an existing topic (dog training), validated by the doc +gate: + +```json +{ + "id": "hunde-audio-wahl", + "title": "Hundetraining: Hoerauswahl", + "requires_extensions": ["ext:ref-audio-choice@1"], + "steps": [ + { + "id": "s1", + "type": "exercise", + "exercise": { + "id": "e1", + "type": "ext:ref-audio-choice", + "prompt": "Hoere die Woerter und waehle das passende Kommando.", + "ext_payload": { + "sentence": "Der Hund soll ___.", + "options": [ + { "audio": "assets/audio/kommando-sitz.mp3", "is_correct": "true" }, + { "audio": "assets/audio/kommando-platz.mp3" } + ] + } + } + } + ] +} +``` + +The consumer half (`renderRefAudioChoice` + `gradeRefAudioChoice`) renders the +sentence over every option's audio reference (a real consumer mounts its audio +buttons there) and grades the chosen option's audio reference against the one +marked `is_correct: "true"`. + +## Example extension: `ext:ref-audio-tiles` + +`src/examples/ext-ref-audio-tiles/` works out the audio-to-translation case: a +spoken source-language sentence, built up as a target-language translation +from word tiles ("listen to the sentence, arrange the translation from +tiles"). Core `word_tiles` already covers the puzzle mechanic (`tiles` + +`accept_orderings`), but carries no audio and no source-language sentence. +Rather than pairing a core `word_tiles` exercise with a bare +`ext_payload.audio` - an untested "core fields + ext_payload coexist" pattern +nobody has exercised yet - this bundles audio + tiles into ONE self-contained +`ext_payload`, matching `ext:ref-dictation`'s and `ext:ref-image-description`'s +established shape. + +The payload is deliberately SELF-CONTAINED: no card reference, everything the +consumer needs sits in `ext_payload`. The engine validates only the SHAPE of +`audio` and `tiles`, plus `accept_orderings` when present - the same +permutation rule core `word_tiles` enforces on its own `accept_orderings` +field. There is no `direction` field: the payload is already +direction-specific by construction (`audio` = source language, `tiles` = +target language). + +Payload rules (engine half `refAudioTilesExtension`): + +| Id | Rule | +|---|---| +| `E-EXT-REFAUDIOTILES-SHAPE` | `ext_payload` must carry `audio` (string) and `tiles` (`string[]`). | +| `E-EXT-REFAUDIOTILES-AUDIO` | `audio` is non-empty. | +| `E-EXT-REFAUDIOTILES-TILES` | `tiles` has at least 2 entries. | +| `E-EXT-REFAUDIOTILES-ORDERINGS` | Each `accept_orderings` entry (when present) is a permutation of `[0..tiles.length - 1]`. | + +A reference lesson on an existing topic (dog training), validated by the doc +gate: + +```json +{ + "id": "hunde-audio-kacheln", + "title": "Hundetraining: Hoerkacheln", + "requires_extensions": ["ext:ref-audio-tiles@1"], + "steps": [ + { + "id": "s1", + "type": "exercise", + "exercise": { + "id": "e1", + "type": "ext:ref-audio-tiles", + "prompt": "Hoere den Satz und baue die Uebersetzung aus den Kacheln.", + "ext_payload": { + "audio": "assets/audio/sit-command-en.mp3", + "tiles": ["Sitz", "bitte", "sofort"] + } + } + } + ] +} +``` + +The consumer half (`renderRefAudioTiles` + `gradeRefAudioTiles`) renders the +prompt over the audio reference and tile list (a real consumer mounts its +audio player and draggable tiles there) and grades the learner's tile ordering +against the canonical order, or against any `accept_orderings` entry when +present - mirroring core word_tiles' own "if omitted, only the canonical order +is accepted" contract. + +## Example extension: `ext:ref-speak-and-record` + +`src/examples/ext-ref-speak-and-record/` works out the speak-and-record case: a +speaker button reads a sentence, a "show" button reveals its text, a "record" +button lets the learner record themselves saying it. Unlike every other +reference extension this one is deliberately UNGRADED: there is nothing to +check a recording against, so the payload carries no `accept` list and the +module exposes no grade function - a consumer treats it as a self-review +activity, not a scored exercise. + +The payload is deliberately SELF-CONTAINED: no card reference, everything the +consumer needs sits in `ext_payload`. `audio` is OPTIONAL: when an author has +not recorded a reference clip, the consumer falls back to on-device speech +synthesis of `sentence`. The engine validates only the SHAPE of `sentence` +(required, non-empty) and `audio` (optional, but a string when present); it +knows nothing about capturing, storing or playing back the learner's OWN +recording, which is entirely consumer-side. + +Payload rules (engine half `refSpeakAndRecordExtension`): + +| Id | Rule | +|---|---| +| `E-EXT-REFSPEAKRECORD-SHAPE` | `ext_payload` must carry `sentence` (string) and an optional `audio` (string). | +| `E-EXT-REFSPEAKRECORD-SENTENCE` | `sentence` is non-empty. | + +A reference lesson on an existing topic (dog training), validated by the doc +gate: + +```json +{ + "id": "hunde-nachsprechen", + "title": "Hundetraining: Nachsprechen", + "requires_extensions": ["ext:ref-speak-and-record@1"], + "steps": [ + { + "id": "s1", + "type": "exercise", + "exercise": { + "id": "e1", + "type": "ext:ref-speak-and-record", + "prompt": "Hoere den Satz, zeige ihn dir an und nimm dich selbst auf.", + "ext_payload": { + "sentence": "Sitz, bitte, sofort.", + "audio": "assets/audio/sitz-bitte-sofort.mp3" + } + } + } + ] +} +``` + +The consumer half (`renderRefSpeakAndRecord`) renders the prompt over the +sentence and, when authored, the audio reference (a real consumer mounts its +speaker/show/record buttons there). There is no grade function: capturing, +storing and reviewing the learner's own recording is entirely consumer-side, +and this extension does not claim to know when a recording is "correct". + The example extensions exist as a DECISION BASIS for adoption: nothing in the app or the content repos references them until that decision is made (adaptive-learner#1579 tracked the exercise-type adoptions; engine#46 tracks diff --git a/src/examples/ext-ref-audio-choice/audio-choice-extension.ts b/src/examples/ext-ref-audio-choice/audio-choice-extension.ts new file mode 100644 index 0000000..2b887fc --- /dev/null +++ b/src/examples/ext-ref-audio-choice/audio-choice-extension.ts @@ -0,0 +1,135 @@ +/** + * Example extension ``ext:ref-audio-choice`` - a gapped sentence with N audio + * options, one of which fills the gap ("listen to the words, pick the one + * that fits"). The flat core schema has no audio-option choice type: + * ``images`` is ``picture_choice``'s visual twin (an exactly-one-correct + * OPTION list), ``multiple_choice``'s options carry text only, and + * ``free_text`` carries no media. So instead of a core-schema change it is + * modelled as a SINGLE ext exercise whose ``ext_payload`` carries the gapped + * sentence plus the audio options (engine#68 - an audio player is a consumer + * capability, not a core field rippling through every exercise type). + * + * The payload is self-contained (Option A): no card reference, everything the + * consumer needs is in ``ext_payload``. The engine validates only the SHAPE + * of ``sentence`` and each option's ``audio``, plus the exactly-one-correct + * contract - mirrored from core picture_choice's own ``E-PIC-ONE-CORRECT`` + * check, not reused directly (that check lives on ``exercise.images``, not an + * ext_payload option list). Deliberately NO label/text field on an option: a + * visible word next to its audio would spoil a listening exercise the same + * way alt-text would spoil an image one (mirrors ``ext:ref-image-description``'s + * reasoning for omitting alt-text). + * + * Note on stability: the payload is a first cut. Open sub-decisions (more than + * two options, partial audio playback speed, per-option distractor audio + * reuse) are exactly why this is an extension - the ``@major`` pin lets the + * payload evolve without migrating core content. Excluded from the published + * build (tsconfig.build); a production adoption uses its own vendor namespace. + */ + +import type { ExerciseExtension } from "../../extensions.js"; +import type { Exercise } from "../../types/lesson-schema.generated.js"; +import type { ValidationIssue } from "../../validate.js"; + +const DOC_ANCHOR = "docs/extensions.md#example-extension-extref-audio-choice"; + +/** One audio option in an ``ext:ref-audio-choice`` payload. */ +interface AudioChoiceOption { + audio: string; + is_correct?: "true"; +} + +/** The ``ext_payload`` shape ``ext:ref-audio-choice`` expects. */ +interface AudioChoicePayload { + sentence: string; + options: AudioChoiceOption[]; +} + +function issue(id: string, message: string): ValidationIssue { + return { path: "/ext_payload", message, id, severity: "error", docAnchor: DOC_ANCHOR }; +} + +function asAudioChoiceOption(value: unknown): AudioChoiceOption | null { + if (typeof value !== "object" || value === null) return null; + const candidate = value as { audio?: unknown; is_correct?: unknown }; + if (typeof candidate.audio !== "string") return null; + if (candidate.is_correct !== undefined && candidate.is_correct !== "true") return null; + return { audio: candidate.audio, is_correct: candidate.is_correct as "true" | undefined }; +} + +/** Read the payload, or null when it is not shaped right. */ +function asAudioChoicePayload(exercise: Exercise): AudioChoicePayload | null { + const payload = exercise.ext_payload as { sentence?: unknown; options?: unknown } | undefined; + if (!payload) return null; + if (typeof payload.sentence !== "string") return null; + if (!Array.isArray(payload.options) || payload.options.length < 2) return null; + const options = payload.options.map(asAudioChoiceOption); + if (options.some((option) => option === null)) return null; + return { sentence: payload.sentence, options: options as AudioChoiceOption[] }; +} + +/** ENGINE half: validate one ``ext:ref-audio-choice`` payload. */ +export const refAudioChoiceExtension: ExerciseExtension = { + type: "ext:ref-audio-choice", + major: 1, + validate(exercise: Exercise): ValidationIssue[] { + const payload = asAudioChoicePayload(exercise); + if (!payload) { + return [ + issue( + "E-EXT-REFAUDIOCHOICE-SHAPE", + "ext:ref-audio-choice requires 'ext_payload' with sentence (string) and at least 2 options ({audio: string, is_correct?: 'true'})", + ), + ]; + } + const issues: ValidationIssue[] = []; + if (payload.sentence.trim() === "" || !payload.sentence.includes("___")) { + issues.push( + issue( + "E-EXT-REFAUDIOCHOICE-SENTENCE", + "ext:ref-audio-choice requires a non-empty sentence containing the gap marker '___'", + ), + ); + } + if (payload.options.some((option) => option.audio.trim() === "")) { + issues.push( + issue("E-EXT-REFAUDIOCHOICE-AUDIO", "ext:ref-audio-choice requires every option's audio to be non-empty"), + ); + } + const correctCount = payload.options.filter((option) => option.is_correct === "true").length; + if (correctCount !== 1) { + issues.push( + issue( + "E-EXT-REFAUDIOCHOICE-CORRECT", + "ext:ref-audio-choice requires exactly one option marked is_correct: 'true'", + ), + ); + } + return issues; + }, +}; + +/** + * CONSUMER half: render the sentence over every option's audio reference. A + * real consumer would mount its audio buttons here; this string form keeps + * the demo framework-agnostic and testable. Falls back to the bare prompt + * when the payload is malformed. + */ +export function renderRefAudioChoice(exercise: Exercise): string { + const payload = asAudioChoicePayload(exercise); + if (!payload) return exercise.prompt; + return [payload.sentence, ...payload.options.map((option) => `[audio] ${option.audio}`)].join("\n"); +} + +/** + * CONSUMER half: grade the learner's chosen option (identified by its + * ``audio`` reference) against the option marked ``is_correct: 'true'``. A + * malformed payload, or a payload with no single correct option, grades as + * incorrect rather than throwing. + */ +export function gradeRefAudioChoice(exercise: Exercise, chosenAudio: string): boolean { + const payload = asAudioChoicePayload(exercise); + if (!payload) return false; + const correct = payload.options.filter((option) => option.is_correct === "true"); + if (correct.length !== 1) return false; + return correct[0]!.audio === chosenAudio; +} diff --git a/src/examples/ext-ref-audio-choice/audio-choice.test.ts b/src/examples/ext-ref-audio-choice/audio-choice.test.ts new file mode 100644 index 0000000..1060390 --- /dev/null +++ b/src/examples/ext-ref-audio-choice/audio-choice.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect } from "vitest"; + +import { + refAudioChoiceExtension, + renderRefAudioChoice, + gradeRefAudioChoice, +} from "./audio-choice-extension.js"; +import { validateLesson } from "../../validate.js"; +import type { Exercise } from "../../types/lesson-schema.generated.js"; + +/** + * End-to-end proof for the example extension ext:ref-audio-choice: a gapped + * sentence with N audio options, one of which fills the gap (engine#68 / + * idea 1: "sentence + two audio buttons, one word each, pick the one that + * fits"). The flat core schema has no audio-option choice type - `images` is + * `picture_choice`'s visual twin, `free_text`/`multiple_choice` carry no + * media - so instead of a core-schema change it is modelled as a single ext + * exercise whose ext_payload carries the gapped sentence plus the audio + * options. + * + * The payload is self-contained (no card reference): everything the consumer + * needs is in ext_payload. The engine validates only the SHAPE of `sentence` + * and each option's `audio`, plus the exactly-one-correct contract that + * mirrors core picture_choice's own E-PIC-ONE-CORRECT rule. + */ + +const OPTION_A = "assets/audio/word-suis.mp3"; +const OPTION_B = "assets/audio/word-es.mp3"; +const SENTENCE = "Je ___ ici."; + +const audioChoiceExercise = (payload: unknown): Exercise => + ({ + id: "e1", + type: "ext:ref-audio-choice", + prompt: "Höre die Wörter und wähle das passende.", + ext_payload: payload, + }) as Exercise; + +const lessonWith = (exercise: Exercise) => ({ + id: "l1", + title: "Audio choice lesson", + requires_extensions: ["ext:ref-audio-choice@1"], + steps: [{ id: "s1", type: "exercise", exercise }], +}); + +const wellFormed = { + sentence: SENTENCE, + options: [ + { audio: OPTION_A, is_correct: "true" }, + { audio: OPTION_B }, + ], +}; + +describe("ext:ref-audio-choice end-to-end", () => { + it("validates a declared + registered audio-choice exercise", () => { + const validated = validateLesson(lessonWith(audioChoiceExercise(wellFormed)), { + extensions: [refAudioChoiceExtension], + }); + expect(validated.errors).toEqual([]); + expect(validated.valid).toBe(true); + }); + + it("is refused loudly without the registry (E-EXT-UNSUPPORTED)", () => { + const refused = validateLesson(lessonWith(audioChoiceExercise(wellFormed))); + expect(refused.errors.some((issue) => issue.id === "E-EXT-UNSUPPORTED")).toBe(true); + }); + + it("rejects a payload without a sentence with a single shape error", () => { + const noSentence = validateLesson( + lessonWith(audioChoiceExercise({ options: wellFormed.options })), + { extensions: [refAudioChoiceExtension] }, + ); + expect(noSentence.errors.some((issue) => issue.id === "E-EXT-REFAUDIOCHOICE-SHAPE")).toBe(true); + }); + + it("rejects a payload without options with a shape error", () => { + const noOptions = validateLesson( + lessonWith(audioChoiceExercise({ sentence: SENTENCE })), + { extensions: [refAudioChoiceExtension] }, + ); + expect(noOptions.errors.some((issue) => issue.id === "E-EXT-REFAUDIOCHOICE-SHAPE")).toBe(true); + }); + + it("rejects fewer than 2 options with a shape error", () => { + const oneOption = validateLesson( + lessonWith(audioChoiceExercise({ sentence: SENTENCE, options: [{ audio: OPTION_A, is_correct: "true" }] })), + { extensions: [refAudioChoiceExtension] }, + ); + expect(oneOption.errors.some((issue) => issue.id === "E-EXT-REFAUDIOCHOICE-SHAPE")).toBe(true); + }); + + it("rejects an option with a non-string audio field with a shape error", () => { + const wrongType = validateLesson( + lessonWith(audioChoiceExercise({ sentence: SENTENCE, options: [{ audio: 1 }, { audio: OPTION_B, is_correct: "true" }] })), + { extensions: [refAudioChoiceExtension] }, + ); + expect(wrongType.errors.some((issue) => issue.id === "E-EXT-REFAUDIOCHOICE-SHAPE")).toBe(true); + }); + + it("requires a non-empty sentence containing the gap marker", () => { + const blankSentence = validateLesson( + lessonWith(audioChoiceExercise({ sentence: " ", options: wellFormed.options })), + { extensions: [refAudioChoiceExtension] }, + ); + expect(blankSentence.errors.some((issue) => issue.id === "E-EXT-REFAUDIOCHOICE-SENTENCE")).toBe(true); + }); + + it("requires the gap marker '___' in the sentence", () => { + const noGap = validateLesson( + lessonWith(audioChoiceExercise({ sentence: "Je suis ici.", options: wellFormed.options })), + { extensions: [refAudioChoiceExtension] }, + ); + expect(noGap.errors.some((issue) => issue.id === "E-EXT-REFAUDIOCHOICE-SENTENCE")).toBe(true); + }); + + it("requires every option's audio to be non-empty", () => { + const blankAudio = validateLesson( + lessonWith( + audioChoiceExercise({ sentence: SENTENCE, options: [{ audio: " ", is_correct: "true" }, { audio: OPTION_B }] }), + ), + { extensions: [refAudioChoiceExtension] }, + ); + expect(blankAudio.errors.some((issue) => issue.id === "E-EXT-REFAUDIOCHOICE-AUDIO")).toBe(true); + }); + + it("requires exactly one option marked is_correct: 'true'", () => { + const none = validateLesson( + lessonWith(audioChoiceExercise({ sentence: SENTENCE, options: [{ audio: OPTION_A }, { audio: OPTION_B }] })), + { extensions: [refAudioChoiceExtension] }, + ); + expect(none.errors.some((issue) => issue.id === "E-EXT-REFAUDIOCHOICE-CORRECT")).toBe(true); + + const both = validateLesson( + lessonWith( + audioChoiceExercise({ + sentence: SENTENCE, + options: [ + { audio: OPTION_A, is_correct: "true" }, + { audio: OPTION_B, is_correct: "true" }, + ], + }), + ), + { extensions: [refAudioChoiceExtension] }, + ); + expect(both.errors.some((issue) => issue.id === "E-EXT-REFAUDIOCHOICE-CORRECT")).toBe(true); + }); + + it("boundary: exactly 2 options, one correct, is the smallest valid payload", () => { + const minimal = validateLesson(lessonWith(audioChoiceExercise(wellFormed)), { + extensions: [refAudioChoiceExtension], + }); + expect(minimal.errors).toEqual([]); + expect(minimal.valid).toBe(true); + }); + + it("renders (consumer half) the sentence over the option audio references", () => { + const rendered = renderRefAudioChoice(audioChoiceExercise(wellFormed)); + expect(rendered).toBe([SENTENCE, `[audio] ${OPTION_A}`, `[audio] ${OPTION_B}`].join("\n")); + }); + + it("renders the bare prompt when the payload is malformed", () => { + const rendered = renderRefAudioChoice(audioChoiceExercise({ options: wellFormed.options })); + expect(rendered).toBe("Höre die Wörter und wähle das passende."); + }); + + it("grades (consumer half) the chosen option's audio reference against the correct one", () => { + const exercise = audioChoiceExercise(wellFormed); + expect(gradeRefAudioChoice(exercise, OPTION_A)).toBe(true); + expect(gradeRefAudioChoice(exercise, OPTION_B)).toBe(false); + }); + + it("grades a malformed payload as incorrect rather than throwing", () => { + expect(gradeRefAudioChoice(audioChoiceExercise({ options: wellFormed.options }), OPTION_A)).toBe(false); + }); +}); diff --git a/src/examples/ext-ref-audio-tiles/audio-tiles-extension.ts b/src/examples/ext-ref-audio-tiles/audio-tiles-extension.ts new file mode 100644 index 0000000..8bb3418 --- /dev/null +++ b/src/examples/ext-ref-audio-tiles/audio-tiles-extension.ts @@ -0,0 +1,143 @@ +/** + * Example extension ``ext:ref-audio-tiles`` - a spoken source-language + * sentence, built up as a target-language translation from word tiles + * ("listen to the sentence, arrange the translation from tiles"). Core + * ``word_tiles`` already covers the puzzle mechanic (``tiles`` + + * ``accept_orderings``), but carries no audio and no source-language + * sentence. Rather than pairing a core ``word_tiles`` exercise with a bare + * ``ext_payload.audio`` - an untested "core fields + ext_payload coexist" + * pattern nobody has exercised yet - this bundles audio + tiles into ONE + * self-contained ``ext_payload`` (engine#68), matching ``ext:ref-dictation``'s + * and ``ext:ref-image-description``'s established shape. + * + * The payload is self-contained (Option A): no card reference, everything the + * consumer needs is in ``ext_payload``. The engine validates only the SHAPE + * of ``audio`` and ``tiles``, plus ``accept_orderings`` when present - the + * same permutation rule core ``word_tiles`` enforces on its own + * ``accept_orderings`` field (mirrored, not reused directly: the core check + * runs on ``exercise.tiles``/``exercise.accept_orderings``, this one on the + * ext_payload's own fields). There is no ``direction`` field: the payload is + * already direction-specific by construction (``audio`` = source language, + * ``tiles`` = target language), so a separate direction toggle would be + * meaningless here. + * + * Note on stability: the payload is a first cut. Open sub-decisions (reverse + * direction, per-tile audio, distractor tiles) are exactly why this is an + * extension - the ``@major`` pin lets the payload evolve without migrating + * core content. Excluded from the published build (tsconfig.build); a + * production adoption uses its own vendor namespace. + */ + +import type { ExerciseExtension } from "../../extensions.js"; +import type { Exercise } from "../../types/lesson-schema.generated.js"; +import type { ValidationIssue } from "../../validate.js"; + +const DOC_ANCHOR = "docs/extensions.md#example-extension-extref-audio-tiles"; + +/** The ``ext_payload`` shape ``ext:ref-audio-tiles`` expects. */ +interface AudioTilesPayload { + audio: string; + tiles: string[]; + accept_orderings?: number[][]; +} + +function issue(id: string, message: string): ValidationIssue { + return { path: "/ext_payload", message, id, severity: "error", docAnchor: DOC_ANCHOR }; +} + +/** Read the payload, or null when it is not shaped right. */ +function asAudioTilesPayload(exercise: Exercise): AudioTilesPayload | null { + const payload = exercise.ext_payload as + | { audio?: unknown; tiles?: unknown; accept_orderings?: unknown } + | undefined; + if (!payload) return null; + if (typeof payload.audio !== "string") return null; + if (!Array.isArray(payload.tiles) || !payload.tiles.every((tile) => typeof tile === "string")) return null; + if (payload.accept_orderings !== undefined) { + const orderings = payload.accept_orderings; + if ( + !Array.isArray(orderings) || + !orderings.every( + (ordering) => Array.isArray(ordering) && ordering.every((index) => typeof index === "number"), + ) + ) { + return null; + } + } + return { + audio: payload.audio, + tiles: payload.tiles as string[], + accept_orderings: payload.accept_orderings as number[][] | undefined, + }; +} + +/** Whether ``ordering`` is a permutation of ``[0..tileCount - 1]``. */ +function isPermutation(ordering: number[], tileCount: number): boolean { + const expected = Array.from({ length: tileCount }, (_unused, index) => index); + const sorted = [...ordering].sort((a, b) => a - b); + return sorted.length === expected.length && sorted.every((value, index) => value === expected[index]); +} + +/** ENGINE half: validate one ``ext:ref-audio-tiles`` payload. */ +export const refAudioTilesExtension: ExerciseExtension = { + type: "ext:ref-audio-tiles", + major: 1, + validate(exercise: Exercise): ValidationIssue[] { + const payload = asAudioTilesPayload(exercise); + if (!payload) { + return [ + issue( + "E-EXT-REFAUDIOTILES-SHAPE", + "ext:ref-audio-tiles requires 'ext_payload' with audio (string) and tiles (string[])", + ), + ]; + } + const issues: ValidationIssue[] = []; + if (payload.audio.trim() === "") { + issues.push(issue("E-EXT-REFAUDIOTILES-AUDIO", "ext:ref-audio-tiles requires a non-empty audio reference")); + } + if (payload.tiles.length < 2) { + issues.push(issue("E-EXT-REFAUDIOTILES-TILES", "ext:ref-audio-tiles requires at least 2 tiles")); + } + for (const ordering of payload.accept_orderings ?? []) { + if (!isPermutation(ordering, payload.tiles.length)) { + issues.push( + issue( + "E-EXT-REFAUDIOTILES-ORDERINGS", + `accept_orderings entry ${JSON.stringify(ordering)} must be a permutation of [0..${payload.tiles.length - 1}]`, + ), + ); + } + } + return issues; + }, +}; + +/** + * CONSUMER half: render the prompt over the audio reference and tile list. A + * real consumer would mount its audio player and draggable tiles here; this + * string form keeps the demo framework-agnostic and testable. Falls back to + * the bare prompt when the payload is malformed. + */ +export function renderRefAudioTiles(exercise: Exercise): string { + const payload = asAudioTilesPayload(exercise); + if (!payload) return exercise.prompt; + return [exercise.prompt, `[audio] ${payload.audio}`, `[tiles] ${payload.tiles.join(", ")}`].join("\n"); +} + +/** + * CONSUMER half: grade the learner's tile ordering against the canonical + * order (tile indices ``[0..n-1]`` in payload order), or against any + * ``accept_orderings`` entry when present - mirrors core word_tiles' own + * grading contract ("if omitted, only the canonical order is accepted"). A + * malformed payload grades as incorrect rather than throwing. + */ +export function gradeRefAudioTiles(exercise: Exercise, chosenOrder: number[]): boolean { + const payload = asAudioTilesPayload(exercise); + if (!payload) return false; + const canonical = payload.tiles.map((_tile, index) => index); + const accepted = payload.accept_orderings ?? [canonical]; + return accepted.some( + (ordering) => ordering.length === chosenOrder.length && ordering.every((value, index) => value === chosenOrder[index]), + ); +} diff --git a/src/examples/ext-ref-audio-tiles/audio-tiles.test.ts b/src/examples/ext-ref-audio-tiles/audio-tiles.test.ts new file mode 100644 index 0000000..e6814ca --- /dev/null +++ b/src/examples/ext-ref-audio-tiles/audio-tiles.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect } from "vitest"; + +import { + refAudioTilesExtension, + renderRefAudioTiles, + gradeRefAudioTiles, +} from "./audio-tiles-extension.js"; +import { validateLesson } from "../../validate.js"; +import type { Exercise } from "../../types/lesson-schema.generated.js"; + +/** + * End-to-end proof for the example extension ext:ref-audio-tiles: a spoken + * source-language sentence, built up as a target-language translation from + * word tiles (engine#68 / idea 2: "sentence shown+spoken in language A, build + * the translation in language B from tiles"). Core word_tiles already covers + * the puzzle mechanic itself (``tiles`` + ``accept_orderings``), but carries + * no audio - so instead of pairing a core exercise with a bare + * ``ext_payload.audio``, this bundles audio + tiles into ONE self-contained + * ext_payload, matching the established "no core fields + ext_payload + * coexistence" pattern the other reference extensions already use. + */ + +const AUDIO = "assets/audio/je-suis-ici.mp3"; +const TILES = ["ich", "bin", "hier"]; + +const audioTilesExercise = (payload: unknown): Exercise => + ({ + id: "e1", + type: "ext:ref-audio-tiles", + prompt: "Höre den Satz und baue die Übersetzung aus den Kacheln.", + ext_payload: payload, + }) as Exercise; + +const lessonWith = (exercise: Exercise) => ({ + id: "l1", + title: "Audio tiles lesson", + requires_extensions: ["ext:ref-audio-tiles@1"], + steps: [{ id: "s1", type: "exercise", exercise }], +}); + +const wellFormed = { audio: AUDIO, tiles: TILES }; + +describe("ext:ref-audio-tiles end-to-end", () => { + it("validates a declared + registered audio-tiles exercise", () => { + const validated = validateLesson(lessonWith(audioTilesExercise(wellFormed)), { + extensions: [refAudioTilesExtension], + }); + expect(validated.errors).toEqual([]); + expect(validated.valid).toBe(true); + }); + + it("is refused loudly without the registry (E-EXT-UNSUPPORTED)", () => { + const refused = validateLesson(lessonWith(audioTilesExercise(wellFormed))); + expect(refused.errors.some((issue) => issue.id === "E-EXT-UNSUPPORTED")).toBe(true); + }); + + it("rejects a payload without audio with a single shape error", () => { + const noAudio = validateLesson(lessonWith(audioTilesExercise({ tiles: TILES })), { + extensions: [refAudioTilesExtension], + }); + expect(noAudio.errors.some((issue) => issue.id === "E-EXT-REFAUDIOTILES-SHAPE")).toBe(true); + }); + + it("rejects a payload without tiles with a shape error", () => { + const noTiles = validateLesson(lessonWith(audioTilesExercise({ audio: AUDIO })), { + extensions: [refAudioTilesExtension], + }); + expect(noTiles.errors.some((issue) => issue.id === "E-EXT-REFAUDIOTILES-SHAPE")).toBe(true); + }); + + it("rejects a non-string tile with a shape error", () => { + const wrongTile = validateLesson( + lessonWith(audioTilesExercise({ audio: AUDIO, tiles: ["ich", 1] })), + { extensions: [refAudioTilesExtension] }, + ); + expect(wrongTile.errors.some((issue) => issue.id === "E-EXT-REFAUDIOTILES-SHAPE")).toBe(true); + }); + + it("requires a non-empty audio reference", () => { + const blankAudio = validateLesson( + lessonWith(audioTilesExercise({ audio: " ", tiles: TILES })), + { extensions: [refAudioTilesExtension] }, + ); + expect(blankAudio.errors.some((issue) => issue.id === "E-EXT-REFAUDIOTILES-AUDIO")).toBe(true); + }); + + it("requires at least 2 tiles", () => { + const oneTile = validateLesson( + lessonWith(audioTilesExercise({ audio: AUDIO, tiles: ["ich"] })), + { extensions: [refAudioTilesExtension] }, + ); + expect(oneTile.errors.some((issue) => issue.id === "E-EXT-REFAUDIOTILES-TILES")).toBe(true); + }); + + it("boundary: exactly 2 tiles is the smallest valid puzzle", () => { + const minimal = validateLesson( + lessonWith(audioTilesExercise({ audio: AUDIO, tiles: ["ich", "hier"] })), + { extensions: [refAudioTilesExtension] }, + ); + expect(minimal.errors).toEqual([]); + expect(minimal.valid).toBe(true); + }); + + it("accepts an omitted accept_orderings (only the canonical order is accepted)", () => { + const noOrderings = validateLesson(lessonWith(audioTilesExercise(wellFormed)), { + extensions: [refAudioTilesExtension], + }); + expect(noOrderings.errors).toEqual([]); + }); + + it("accepts a valid accept_orderings permutation", () => { + const validOrdering = validateLesson( + lessonWith(audioTilesExercise({ ...wellFormed, accept_orderings: [[0, 1, 2]] })), + { extensions: [refAudioTilesExtension] }, + ); + expect(validOrdering.errors).toEqual([]); + }); + + it("rejects an accept_orderings entry that is not a permutation of the tile indices", () => { + const badOrdering = validateLesson( + lessonWith(audioTilesExercise({ ...wellFormed, accept_orderings: [[0, 1, 1]] })), + { extensions: [refAudioTilesExtension] }, + ); + expect(badOrdering.errors.some((issue) => issue.id === "E-EXT-REFAUDIOTILES-ORDERINGS")).toBe(true); + }); + + it("rejects an accept_orderings entry of the wrong length", () => { + const shortOrdering = validateLesson( + lessonWith(audioTilesExercise({ ...wellFormed, accept_orderings: [[0, 1]] })), + { extensions: [refAudioTilesExtension] }, + ); + expect(shortOrdering.errors.some((issue) => issue.id === "E-EXT-REFAUDIOTILES-ORDERINGS")).toBe(true); + }); + + it("renders (consumer half) the prompt over the audio reference and tiles", () => { + const rendered = renderRefAudioTiles(audioTilesExercise(wellFormed)); + expect(rendered).toBe( + ["Höre den Satz und baue die Übersetzung aus den Kacheln.", `[audio] ${AUDIO}`, `[tiles] ${TILES.join(", ")}`].join( + "\n", + ), + ); + }); + + it("renders the bare prompt when the payload is malformed", () => { + const rendered = renderRefAudioTiles(audioTilesExercise({ tiles: TILES })); + expect(rendered).toBe("Höre den Satz und baue die Übersetzung aus den Kacheln."); + }); + + it("grades (consumer half) the canonical order when accept_orderings is absent", () => { + const exercise = audioTilesExercise(wellFormed); + expect(gradeRefAudioTiles(exercise, [0, 1, 2])).toBe(true); + expect(gradeRefAudioTiles(exercise, [1, 0, 2])).toBe(false); + }); + + it("grades an accepted alternative ordering as correct", () => { + const exercise = audioTilesExercise({ ...wellFormed, accept_orderings: [[0, 2, 1]] }); + expect(gradeRefAudioTiles(exercise, [0, 2, 1])).toBe(true); + expect(gradeRefAudioTiles(exercise, [0, 1, 2])).toBe(false); + }); + + it("grades a malformed payload as incorrect rather than throwing", () => { + expect(gradeRefAudioTiles(audioTilesExercise({ tiles: TILES }), [0, 1, 2])).toBe(false); + }); +}); diff --git a/src/examples/ext-ref-speak-and-record/speak-and-record-extension.ts b/src/examples/ext-ref-speak-and-record/speak-and-record-extension.ts new file mode 100644 index 0000000..4a4d2b9 --- /dev/null +++ b/src/examples/ext-ref-speak-and-record/speak-and-record-extension.ts @@ -0,0 +1,88 @@ +/** + * Example extension ``ext:ref-speak-and-record`` - a speaker button reads a + * sentence, a "show" button reveals its text, a "record" button lets the + * learner record themselves saying it (engine#68 / idea 3). Unlike every + * other reference extension this one is deliberately UNGRADED: there is + * nothing to check a recording against, so the payload carries no ``accept`` + * list and this module exposes no grade function - a consumer treats it as a + * self-review activity, not a scored exercise (see the adaptive-learner + * adoption notes for how an ungraded exercise still flows through the + * standard step-completion pipeline with zero SRS rows). + * + * The payload is self-contained (Option A): no card reference, everything the + * consumer needs is in ``ext_payload``. ``audio`` is OPTIONAL: when an author + * has not recorded a reference clip, the consumer falls back to on-device + * speech synthesis of ``sentence`` (the app's existing TTS affordance already + * does this for plain text elsewhere). The engine validates only the SHAPE of + * ``sentence`` (required, non-empty) and ``audio`` (optional, but a string + * when present) - it knows nothing about capturing, storing or playing back + * the learner's OWN recording, which is entirely consumer-side (genuinely new + * capability there: no Blob/MediaRecorder storage precedent existed before + * this feature). + * + * Note on stability: the payload is a first cut. Open sub-decisions (a target + * recording length, multiple reference speakers, phoneme-level feedback) are + * exactly why this is an extension - the ``@major`` pin lets the payload + * evolve without migrating core content. Excluded from the published build + * (tsconfig.build); a production adoption uses its own vendor namespace. + */ + +import type { ExerciseExtension } from "../../extensions.js"; +import type { Exercise } from "../../types/lesson-schema.generated.js"; +import type { ValidationIssue } from "../../validate.js"; + +const DOC_ANCHOR = "docs/extensions.md#example-extension-extref-speak-and-record"; + +/** The ``ext_payload`` shape ``ext:ref-speak-and-record`` expects. */ +interface SpeakAndRecordPayload { + sentence: string; + audio?: string; +} + +function issue(id: string, message: string): ValidationIssue { + return { path: "/ext_payload", message, id, severity: "error", docAnchor: DOC_ANCHOR }; +} + +/** Read the payload, or null when it is not shaped right. */ +function asSpeakAndRecordPayload(exercise: Exercise): SpeakAndRecordPayload | null { + const payload = exercise.ext_payload as { sentence?: unknown; audio?: unknown } | undefined; + if (!payload) return null; + if (typeof payload.sentence !== "string") return null; + if (payload.audio !== undefined && typeof payload.audio !== "string") return null; + return { sentence: payload.sentence, audio: payload.audio }; +} + +/** ENGINE half: validate one ``ext:ref-speak-and-record`` payload. */ +export const refSpeakAndRecordExtension: ExerciseExtension = { + type: "ext:ref-speak-and-record", + major: 1, + validate(exercise: Exercise): ValidationIssue[] { + const payload = asSpeakAndRecordPayload(exercise); + if (!payload) { + return [ + issue( + "E-EXT-REFSPEAKRECORD-SHAPE", + "ext:ref-speak-and-record requires 'ext_payload' with sentence (string) and an optional audio (string)", + ), + ]; + } + if (payload.sentence.trim() === "") { + return [issue("E-EXT-REFSPEAKRECORD-SENTENCE", "ext:ref-speak-and-record requires a non-empty sentence")]; + } + return []; + }, +}; + +/** + * CONSUMER half: render the prompt over the sentence and, when authored, the + * audio reference. A real consumer would mount its speaker/show/record + * buttons here; this string form keeps the demo framework-agnostic and + * testable. Falls back to the bare prompt when the payload is malformed. + */ +export function renderRefSpeakAndRecord(exercise: Exercise): string { + const payload = asSpeakAndRecordPayload(exercise); + if (!payload) return exercise.prompt; + const lines = [exercise.prompt, `[sentence] ${payload.sentence}`]; + if (payload.audio) lines.push(`[audio] ${payload.audio}`); + return lines.join("\n"); +} diff --git a/src/examples/ext-ref-speak-and-record/speak-and-record.test.ts b/src/examples/ext-ref-speak-and-record/speak-and-record.test.ts new file mode 100644 index 0000000..2167c80 --- /dev/null +++ b/src/examples/ext-ref-speak-and-record/speak-and-record.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from "vitest"; + +import { refSpeakAndRecordExtension, renderRefSpeakAndRecord } from "./speak-and-record-extension.js"; +import { validateLesson } from "../../validate.js"; +import type { Exercise } from "../../types/lesson-schema.generated.js"; + +/** + * End-to-end proof for the example extension ext:ref-speak-and-record: a + * speaker button reads a sentence, a "show" button reveals its text, a + * "record" button lets the learner record themselves saying it (engine#68 / + * idea 3). Unlike every other reference extension this one is deliberately + * UNGRADED: there is nothing to check a recording against, so the payload + * carries no accept list and the extension exposes no grade function. + * + * The payload is self-contained (no card reference): everything the consumer + * needs is in ext_payload. ``audio`` is optional - when absent the consumer + * falls back to on-device speech synthesis of ``sentence``. + */ + +const SENTENCE = "Je suis ici et tres content."; +const AUDIO = "assets/audio/je-suis-ici-content.mp3"; + +const speakAndRecordExercise = (payload: unknown): Exercise => + ({ + id: "e1", + type: "ext:ref-speak-and-record", + prompt: "Höre den Satz, zeige ihn dir an und nimm dich selbst auf.", + ext_payload: payload, + }) as Exercise; + +const lessonWith = (exercise: Exercise) => ({ + id: "l1", + title: "Speak and record lesson", + requires_extensions: ["ext:ref-speak-and-record@1"], + steps: [{ id: "s1", type: "exercise", exercise }], +}); + +describe("ext:ref-speak-and-record end-to-end", () => { + it("validates a declared + registered exercise with authored audio", () => { + const validated = validateLesson(lessonWith(speakAndRecordExercise({ sentence: SENTENCE, audio: AUDIO })), { + extensions: [refSpeakAndRecordExtension], + }); + expect(validated.errors).toEqual([]); + expect(validated.valid).toBe(true); + }); + + it("validates a declared + registered exercise without authored audio (TTS fallback)", () => { + const validated = validateLesson(lessonWith(speakAndRecordExercise({ sentence: SENTENCE })), { + extensions: [refSpeakAndRecordExtension], + }); + expect(validated.errors).toEqual([]); + expect(validated.valid).toBe(true); + }); + + it("is refused loudly without the registry (E-EXT-UNSUPPORTED)", () => { + const refused = validateLesson(lessonWith(speakAndRecordExercise({ sentence: SENTENCE }))); + expect(refused.errors.some((issue) => issue.id === "E-EXT-UNSUPPORTED")).toBe(true); + }); + + it("rejects a payload without a sentence with a shape error", () => { + const noSentence = validateLesson(lessonWith(speakAndRecordExercise({ audio: AUDIO })), { + extensions: [refSpeakAndRecordExtension], + }); + expect(noSentence.errors.some((issue) => issue.id === "E-EXT-REFSPEAKRECORD-SHAPE")).toBe(true); + }); + + it("rejects a non-string audio field with a shape error", () => { + const wrongType = validateLesson(lessonWith(speakAndRecordExercise({ sentence: SENTENCE, audio: 1 })), { + extensions: [refSpeakAndRecordExtension], + }); + expect(wrongType.errors.some((issue) => issue.id === "E-EXT-REFSPEAKRECORD-SHAPE")).toBe(true); + }); + + it("requires a non-empty sentence", () => { + const blankSentence = validateLesson(lessonWith(speakAndRecordExercise({ sentence: " " })), { + extensions: [refSpeakAndRecordExtension], + }); + expect(blankSentence.errors.some((issue) => issue.id === "E-EXT-REFSPEAKRECORD-SENTENCE")).toBe(true); + }); + + it("boundary: sentence alone (no audio) is the smallest valid payload", () => { + const minimal = validateLesson(lessonWith(speakAndRecordExercise({ sentence: "Salut." })), { + extensions: [refSpeakAndRecordExtension], + }); + expect(minimal.errors).toEqual([]); + expect(minimal.valid).toBe(true); + }); + + it("renders (consumer half) the prompt over the sentence and audio reference", () => { + const rendered = renderRefSpeakAndRecord(speakAndRecordExercise({ sentence: SENTENCE, audio: AUDIO })); + expect(rendered).toBe( + ["Höre den Satz, zeige ihn dir an und nimm dich selbst auf.", `[sentence] ${SENTENCE}`, `[audio] ${AUDIO}`].join( + "\n", + ), + ); + }); + + it("renders without an [audio] line when no audio was authored", () => { + const rendered = renderRefSpeakAndRecord(speakAndRecordExercise({ sentence: SENTENCE })); + expect(rendered).toBe( + ["Höre den Satz, zeige ihn dir an und nimm dich selbst auf.", `[sentence] ${SENTENCE}`].join("\n"), + ); + }); + + it("renders the bare prompt when the payload is malformed", () => { + const rendered = renderRefSpeakAndRecord(speakAndRecordExercise({ audio: AUDIO })); + expect(rendered).toBe("Höre den Satz, zeige ihn dir an und nimm dich selbst auf."); + }); +});