Skip to content

Repository files navigation

adaptive-learner-content-template

content validation engine on npm

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.

What's inside

  • 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 pinned learn-content-engine schema mirror; engine-version.txt holds 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's docs/lesson-format.md.

Quick start

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: false with since and reason keeps the prose gate non-blocking while a backlog from before the gate is worked off. Absent means blocking.
  • accepted_warnings records author warnings the repository decided to keep, with the date, the reason, an optional link to the decision, and the stable_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 with python3 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.py

Or 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.

Export a set for AI review

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>.yaml

Direct (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.json

The 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).

Export a graded quiz to PDF (school tests)

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.

Export a set to Anki (.apkg)

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.apkg

What 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.

Generate exercises with AI (optional)

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

Options

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.

What happens, and what you still owe

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:

  1. Engine semantic gate (cloze ___ markers equal the blanks, card_ids integrity, multiselect disjointness). It runs when the pinned learn-content-engine is installed, otherwise it is deferred to CI. The plain validator does not cover it.
  2. 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.

How it stays current

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.

About

GitHub template for building your own Adaptive Learner content: schema, validator, CI, AI generator, and one example set. Use this template to start your own content repo.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages