A GitHub template for building your own Adaptive Learner content: a Git repository of plain lesson files that the app loads directly and no vendor can lock away.
Click “Use this template” → Create a new repository (not Fork) to get a fresh, independent copy under your own account, then clone it.
This template is the clean scaffold: schema, validator, CI, authoring templates, an AI generator, and one small example set. It ships no real content: you replace the example with your own.
manifest.yaml: the root manifest listing your sets (one example set to start).sets/en/es-a1/: one minimal, valid example lesson + its set manifest.schema/: the pinnedlearn-content-engineschema mirror;engine-version.txtholds the pinned engine version and is the source of truth. This is what your content is validated against, independent of the app.templates/: starting-point lessons per domain (language / programming / knowledge).scripts/validate_content.py: the local validator.scripts/generate_exercises.py: an optional BYOK AI exercise generator.generated/: staging area for AI drafts (never shipped directly)..github/workflows/: CI that validates every push/PR against the pinned engine.docs/: GETTING-STARTED.md and a local LESSON-FORMAT.md. The canonical, test-validated format reference is the engine'sdocs/lesson-format.md.
You only need make and python3. The first make validate sets up a
local environment for you (no manual pip, no virtualenv, no Poetry):
# 1. Use this template -> your own repo -> clone it
git clone https://github.com/<you>/<your-content-repo>.git
cd <your-content-repo>
# 2. Validate the example set. First run creates .venv and installs deps;
# later runs reuse it. Exit 0 == all sets pass.
make validate
# 3. Replace the example with your own lesson, then re-run make validate + commit.One line to delete when you go live: the example set carries
visibility: hidden in manifest.yaml. A template is copied, so that
setting is inherited by your repository, and it keeps the demo out of the
Adaptive Learner "Discover" list until you replace it. When your own set is
ready, delete the visibility: hidden line (and the comment above it) from
its manifest entry, otherwise learners will not see your content. Hiding is
the safe default; showing is the conscious act.
Before you push, make lint runs the same semantic engine gate as CI
(Engine conformance): it installs the engine release pinned in
schema/engine-version.txt into node_modules/ (gitignored; needs Node.js
and npm) and checks every lesson and manifest with the engine's rule ids
(E-CARD-REF & co.). make lint-warnings additionally prints the engine gate's warnings (W-*).
make prose-check is the third, cheapest gate (Python only, no engine): it
refuses the em dash and the characters that render as nothing (zero-width
space, byte-order mark, soft hyphen, directional marks) in every tracked file,
and it refuses German written with the letter pairs ae, oe, ue and ss
where the word needs ä, ö, ü or ß. The second check never matches the letter
pairs themselves (a rule on ue would fire on value, true and queue). It
reads scripts/umlaut_stems.json: short German words as whole words, longer
ones through stems that occur in no correct German word, no English word and
none of the foreign-language material of the content repositories. The file is
generated from the Debian dictionaries by scripts/build_umlaut_stems.py
(see its docstring for the run) and committed; it finds 98 percent of the
German dictionary's umlaut words and flags none of its correct words. The
Umlaut data workflow re-proves both with the dictionaries installed whenever
the gate or its data changes. A new false alarm on a name or a
romanisation goes into FOREIGN_LOOKALIKES in scripts/check_prose.py,
together with the run that found it. Code is exempt from the check - an
identifier is spelled by whoever wrote it - which means the code-bearing and
id fields of a lesson file (passage, sentence, tokens, code, id,
stable_id, ...) and the fenced blocks in a theory body.
The house style writes a hyphen or a comma. Ellipsis, en dash and no-break
space stay allowed - they are legitimate typography. schema/ is out of
scope: it mirrors the pinned engine release byte for byte, so its text belongs
to the engine. CI runs the same gate, self-test first.
Two quality decisions differ from repository to repository and live in
.github/quality-state.json, not in the workflows (which stay identical
everywhere):
prose_gate.blocking: falsewithsinceandreasonkeeps the prose gate non-blocking while a backlog from before the gate is worked off. Absent means blocking.accepted_warningsrecords author warnings the repository decided to keep, with the date, the reason, an optional link to the decision, and thestable_ids of the elements. The warning summary shows the decision next to the number and compares the recorded elements with the current ones, so a swap at the same count is reported, not hidden. Record one withpython3 scripts/quality_state.py accept RULE <warnings-output> --reason "...".
The tooling is shared between the content repositories, and a copy nobody
compares drifts. .github/ownership.json names the owner of every path:
template (author tooling: validation, drift checks, prose gate, exports,
Makefile), hub (search index and federation, owned by
adaptive-learner-content), hub-only (registry and AI review, only the hub
carries them), repo (each repository's own content, manifests, README and
tests that pin its own sets) or engine (the schema mirror, compared by the
schema drift gate). Every file a repository copies is listed by name. The
Ownership workflow runs scripts/check_ownership.py nightly against the
owners' main branches, with the template's current ownership file, and opens
or updates one issue while a copy differs, is missing, or a tracked file has
no row; it closes the issue once everything agrees. A new file without a row
already fails the test suite of the PR that adds it. A file that belongs to
one repository only gets a repo row here in the template, so it is the
repository's by decision and not by omission.
No make (e.g. Windows without WSL)? Two options: run the validator in a
virtualenv yourself:
python3 -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
python3 scripts/validate_content.pyOr just commit and let the GitHub Actions CI validate (it runs the same
checks). Installing the deps globally with a bare pip install fails on
modern Debian/Ubuntu/macOS (PEP 668, "externally-managed-environment");
the virtualenv above is why.
Full walkthrough: docs/GETTING-STARTED.md.
scripts/export_set.py writes all lessons of ONE set into a single
YAML (or JSON) file so an AI assistant or a human can review the whole
set in one pass (syntax, correctness, consistency across lessons).
Recommended (via make; reuses the local environment make validate set up):
make export ARGS="es-a1 --lang en"
# -> exports/es-a1-en-<timestamp>.yamlDirect (fallback; run it inside the venv from the Quick start):
python3 scripts/export_set.py es-a1 --lang en
python3 scripts/export_set.py es-a1 --lang en --format json --out /tmp/review.jsonThe slug is the set id from the root manifest.yaml (example-set) or
the folder name of the set path (es-a1); when the same folder name
exists under several source-language directories, --lang (default
de) picks the sets/<lang>/ directory. Non-ASCII characters stay
real UTF-8. An unknown slug aborts with a list of the available sets.
For a large set, --split-size N writes multiple self-contained files
of at most N lessons each instead of one huge file, e.g.
make export ARGS="es-a1 --lang en --split-size 8" (each part keeps its
own review_instructions copy, so any one file can be handed to an AI
on its own). Cannot be combined with --out.
The export is self-contained: its first field review_instructions
holds the complete review prompt from
docs/ai-review-prompt-template.md
(read at runtime, not copied into the script). The export file can be
handed to a review AI as-is, without manually prepending a prompt. Edit
the review instructions in that template file and keep the sibling
content repos in sync.
Read-only snapshot, NOT a re-import format: nothing reads the
export back. Changes flow only through the individual schema-validated
lesson JSONs under sets/. The exports/ folder is gitignored.
Full usage guide and best practices (incl. the source-chapter workflow):
docs/export-set-usage.md (English) /
docs/export-set-usage.de.md (Deutsch).
scripts/export_quiz_pdf.py turns a lesson that carries a graded-quiz
exercise (an ext:*-graded-quiz: a scored question set, points per
question, optional partial credit on multi-select, an optional
percentage pass threshold) into two print-ready PDFs:
python3 scripts/export_quiz_pdf.py path/to/graded-quiz.json --out-dir out/
# -> out/<id>-test.pdf (question paper for students, no answers)
# -> out/<id>-loesung.pdf (answer sheet for the teacher)The test paper shows the questions with blank checkboxes / answer lines and the points; the answer sheet shows the correct answers, the points, a partial-credit note, and the pass threshold. This is a consumer tool - it renders one presentation of a canonical lesson and does not invoke the engine, so it is independent of the pinned engine version.
Where the lesson may live: the content gate (make lint) accepts the
adopted ext: types, ext:al-graded-quiz among them (the adopted list and
its self-test live in scripts/validate_with_engine.mjs), so a graded-quiz
lesson can sit under sets/ like any other. The tool also reads a lesson
kept elsewhere; a runnable sample lives in
tests/fixtures/graded-quiz-sample.json.
scripts/export_anki.py turns one set into an Anki deck, so the content can
travel to the largest spaced-repetition ecosystem without leaving this
format behind:
make export-anki ARGS="es-a1 --lang en"
# -> exports/es-a1-en-<timestamp>.apkg (import via Anki: File > Import)
python3 scripts/export_anki.py es-a1 --lang en --out /tmp/es-a1.apkgWhat becomes a note: every card (front / back), free_text (prompt /
canonical answer, alternatives listed), cloze (one {{cN::...}} per
blank, the explanation as extra), multiple_choice (lettered options /
the correct letters), matching (one note per pair), word_tiles (tiles
in alphabetical order / the sentence). Skipped and named in the report,
never silently: theory steps, picture_choice (assets are not exported),
matching with from_cards (its pairs are the cards, exported once), and
every ext: type.
Note identity carries over: a note's GUID derives from the element's
stable_id (else lesson id plus element id), so re-importing a newer
export into Anki updates the notes and keeps the learner's scheduling
instead of duplicating them. This is a consumer tool like the PDF export:
it renders one presentation of the canonical lessons and does not invoke
the engine. genanki (MIT) comes with make setup.
scripts/generate_exercises.py turns a topic into a full language
lesson with a BYOK model (Anthropic / OpenAI / Gemini) and gates every
draft through the validator before writing it into the generated/
staging folder. It is language-focused (target and source differ). For a
knowledge set (material written in the same language it teaches,
source == target), the generator is not the right tool; hand-author from
templates/knowledge/ instead.
First set your provider key. It is read from the environment (BYOK) and never committed:
export ANTHROPIC_API_KEY="sk-..." # or OPENAI_API_KEY / GEMINI_API_KEY (Gemini also accepts GOOGLE_API_KEY)Recommended (via make; reuses the local environment make validate set up):
make generate ARGS="--topic 'Ordering food in a café' --target-lang fr --source-lang en --level A1 --set-id fr-a1"Direct (fallback; run it inside the venv from the Quick start):
python3 scripts/generate_exercises.py \
--topic "Ordering food in a café" \
--target-lang fr --source-lang en --level A1 --set-id fr-a1| Flag | Default | Meaning |
|---|---|---|
--topic |
(required) | What the lesson is about. |
--target-lang |
(required) | The language the learner studies (BCP-47, e.g. fr). |
--source-lang |
(required) | The explanation language (BCP-47, e.g. en). Must differ from the target. |
--level |
A1 |
CEFR level. |
--count |
6 |
Exercises to request. The effective minimum is 5 (a smaller value is treated as 5, and the quality gate requires at least 5). |
--set-id |
generated-set |
Staging subfolder under generated/. |
--provider |
anthropic |
anthropic | openai | gemini. Or set AL_GEN_PROVIDER. |
--model |
provider default | Override the model (claude-sonnet-4-5 / gpt-4o / gemini-2.5-flash). |
--retries |
3 |
Extra attempts when a draft fails validation before it is discarded. |
--out |
generated |
Staging directory. |
The script pins the exact lesson-schema JSON in the prompt, parses the
model's reply, and runs it through validate_content.py. If validation
fails, the errors go back to the model and it retries (up to --retries);
a draft that never validates is discarded, not written. A valid draft
lands in generated/<set-id>/, never directly in sets/.
Two gates remain after generation, neither of them automatic:
- Engine semantic gate (cloze
___markers equal the blanks,card_idsintegrity, multiselect disjointness). It runs when the pinnedlearn-content-engineis installed, otherwise it is deferred to CI. The plain validator does not cover it. - Native-speaker review for a language you do not speak natively. No validator catches an unnatural phrasing or a wrong romanization. Machine-generated, then human-verified, is the only trustworthy order.
When a draft is good, move it from generated/ into your set under
sets/<source>/<target>-<level>/lessons/, register it in the set
manifest, and re-run make validate.
Your content is validated against the pinned engine version in
schema/engine-version.txt on every push and pull request (structural +
semantic + drift gates in .github/workflows/). A green CI means your
content is valid for every consumer of that engine release.
Green does not mean current, and the two gates cover different questions.
The drift gate compares your schema mirror against the release you pin, so
it stays green however old that pin is - it is there to catch a hand-edit on
the mirror. The currency check (engine-currency.yml) runs nightly and
compares the pin against the npm dist-tag this repo follows (latest by
default, set ENGINE_DIST_TAG in that workflow if you track another
channel). When they differ it opens an issue and updates it, and it closes
that issue once the pin catches up. It never moves the pin: that stays a
deliberate PR which bumps schema/engine-version.txt and refreshes the
mirror (python3 scripts/check_schema_drift.py --update) in the same
commit.
A third nightly job (registry-drift.yml) compares the ext: types this
repository's engine gate accepts (scripts/adopted-extensions.mjs) with the
ones the app renders. A type the app renders but this gate refuses makes a
valid lesson unmergeable here; a type the gate accepts but the app refuses lets
a lesson in that the app will not load. Differences open one issue, which
closes itself once the lists agree.
Moving the pin matters even when nothing in your content changes. New schema
fields are optional, so old content stays valid - but a new author lint
applies to lessons you wrote long ago and reaches them only once the pin
moves. That is why the engine gate also runs --warnings in CI as a
non-blocking step: the findings show up in the job summary instead of
waiting for someone to run make lint-warnings by hand.
Background and prompt recipes: the blog post Build Your Own Lessons for
Adaptive Learner. Licensed MIT (see LICENSE); your authored
content may carry its own license via each set manifest's metadata.license.