fullmark user guide

Write a quiz in Markdown, take it in the browser, get it graded

1. What fullmark is

fullmark turns one Markdown file into one HTML file that is a complete quiz. You open the HTML by double-click. It needs no server and no internet, works on a phone, grades itself, and can export your answers as a JSON file so an AI can review them.

The usual loop:

  1. An AI (or you) writes the quiz as Markdown.
  2. quiz check finds mistakes, with line numbers.
  3. quiz build makes the HTML.
  4. The learner answers, submits, and exports results.json.
  5. quiz grade turns that file into a Markdown report; an AI grades essays and code from it.

The page uses a muted pastel palette: soft paper in light mode and soft slate in dark mode. Set theme: light or theme: dark in the front matter to lock it.

2. Install

uv tool install --editable <path to this repo>
quiz --help

Open a new terminal after installing. After pulling a version that adds a dependency (0.9.0 adds tomli-w and rich), run uv tool install --editable <path to this repo> --reinstall, or the installed quiz fails with ModuleNotFoundError. For development, run uv sync inside the project and use uv run quiz ... and uv run pytest.

3. Quick start

mkdir $HOME\quiz-try; cd $HOME\quiz-try
quiz new calculus
quiz check calculus-1.md
quiz build calculus-1.md --open

quiz new copies a template and its images. quiz check should print 0 error(s), 0 warning(s). quiz build --open writes calculus-1.html and opens it in your browser.

The templates:

Template Shows
calculus LaTeX math, numeric and labeled-field answers, blanks, a graph, instant feedback
rust Highlighted code, blanks inside code, a code answer, shuffled choices
ielts A 60-minute timer, two essays, auto-submit, a band-score report
medical Anatomy and physiology: an ordered pathway, matching, calculations with units, labeled fields, an essay with a rubric
exam A closed-book exam: 20-minute auto-submit, no feedback until the end, shuffling, a question bank, a pass mark
survey A questionnaire: no score, choices, scales, a ranking and free text; read it with quiz grade
blank A short skeleton with one example of every question type

4. Writing a quiz

A quiz file has three layers:

---
title: "Capitals: Europe"
time_limit: 10m
feedback: instant
---

# Easy ones

## What is the capital of France? {tags=geography}

- [ ] Lyon
- [x] Paris
- [ ] Nice

> [!hint]
> It has a famous tower.

> [!explanation]
> Paris has been the capital since 987.

Put a colon inside a YAML value in quotes, as in the title above.

Question types

Type How to write it
single A task list with exactly one [x] (type is inferred)
multi A task list with two or more [x] (inferred), or {type=multi}
truefalse {type=truefalse} and an answer block with value: true
numeric {type=numeric} and an answer block with value, tolerance
short {type=short} and an answer block with accept and optional patterns
blanks {type=blanks} and [[answer\|variant]] written inline in the text
fields {type=fields} and an answer block listing labeled boxes
ordering {type=ordering} and a list written in the correct order; the page shuffles it
matching {type=matching} and a list of left :: right bullets; :: right adds a distractor
essay {type=essay}, an answer block with min_words and a rubric
code {type=code}, an answer block with language, starter, rubric
scale {type=scale} and an optional answer block with min, max, labels; always ungraded

An answer block is a fenced block whose info string is answer, containing YAML:

## Evaluate $\int_0^1 x^2\,dx$. {type=numeric}

```answer
value: 0.3333
tolerance: 0.001
```

Numeric answers accept integers, decimals, 1e-3 and fractions such as 1/3. Short answers ignore case and extra spaces unless you add {case=sensitive}.

Follow-up text on a choice

End a choice with {input} or {input="Prompt text"} (default prompt “Please specify”) to ask for a few words when it is picked, for example “Something else” followed by “What is it?”. It works on single and multi questions in quizzes and surveys, graded or not.

- [ ] A lab project
- [ ] Something else {input="What is it?"}

A labelled text field appears under the choice while it is selected and takes focus; unselect the choice and the field hides but keeps the text. The text is never graded. It is exported as inputs next to choice or choices (keys are the original choice numbers), and quiz grade shows Something else: a hackathon. {input} must end the line, the prompt is plain text in double quotes, and it is an error on any other question type.

Math, code and media

Everything is embedded in the HTML as data, so the file stays self-contained. Video and web URLs are not supported.

Settings you will use most

Key Meaning
mode quiz (default) or survey (every question ungraded, no score)
time_limit 90s, 45m, 1h30m; leave out for untimed
on_timeout submit (default) or warn
feedback end (default) or instant
theme auto (default, with a toggle), or light / dark to lock it
pick question bank: draw this many questions at random per attempt (see below)
shuffle_choices shuffle the order of choices
pass_mark percent of auto-graded points needed to pass

Lock theme: light for an exam simulation whose real paper is printed (the IELTS template does); leave auto for practice drills.

The complete reference is one command away: quiz guide.

Question banks

Write more questions than one attempt needs and let fullmark draw a random subset each time the learner presses Start. Two ways, which cannot be mixed:

---
title: Calculus bank
pick: 10              # draw 10 of all questions
---

or per section, where sections without pick keep every question:

# Limits {pick=3}

The draw is saved with the attempt, so reloading or resuming shows the same questions. Scores, the progress counter and the export cover the drawn questions only. The export records attempt.bank (seed, pool, drawn), and quiz grade checks the draw against the quiz settings and warns if it does not fit. quiz check rejects a pick larger than its pool.

Questionnaires

Not every question has a right answer. Two front-matter and heading settings turn fullmark into a questionnaire tool:

Ungraded questions can be single, multi (write no [x], and add {type=multi}), short (no answer block), fields (labels only), essay (no rubric), ordering (a ranking, shown in the written order) and scale. The other types need a right answer and are errors. [x], pass_mark, pick, feedback: instant and multi_scoring are errors in a survey. Branching, answers from several people and required answers are not supported.

quiz grade results.json --quiz quiz.md turns the export into a report titled “Answers” for a survey, or adds an Answers section to a quiz report; --json gives each answer as answer_text. Start from quiz new survey. The details, with examples, are in quiz guide.

Collect answers directly

When a tool or an agent is waiting for the answers, skip the download. quiz serve quiz.md --open builds the page in memory, serves it on 127.0.0.1 (a free port, this machine only) and opens it. After the usual confirm step, Finish or Submit also sends the results to the server, which checks them against results-v1.schema.json and the quiz id, writes <quiz-id>-results-<timestamp>.json next to the source (or --out; an existing file is never overwritten, -2 is added instead) and prints the path. The page then says “Answers sent. You can close this tab.” and Export answers stays available. If the send fails it says “Could not send; use Export answers” and nothing is lost.

Review a quiz an agent wrote

The risk in an agent-written quiz is a wrong answer key, or a question that is not in your material. quiz review quiz.md [--out review.json] [--since review.json] [--once] [--open] [--timeout 1800] [--json] serves a review form: one card per question, showing the question as it is rendered, its answer key, explanation, points, tags and source line. The learner chooses Keep, Fix (what should change?) or Drop (why?), may tick problems (key-wrong, not-in-material, unclear, too-easy, too-hard, duplicate) and add a note. Cards with no verdict count as not reviewed. The page counts cards, not fields: the header reads Reviewed N/M, the question nav has one button per card, and a card is reviewed only when it has a verdict (problems and a note alone do not count). The flag checkbox is hidden. Finish writes review.json beside the quiz (never over an existing file: review-2.json; with --out or --since it is merged into that file). --since review.json asks only about questions that are new or changed since that review.

quiz check quiz.md --review review.json then fails (exit 1) while a flagged question is still wrong:

Rule Level When
review.drop error a question marked drop is still in the quiz
review.fix error a question marked fix is unchanged (the message has the reviewer’s text)
review.key-wrong error key-wrong is ticked and the question is unchanged, whatever the verdict
review.changed info a fix question changed; re-review it with --since
review.unreviewed warning no verdict, or the question changed after it was kept

Questions match review items by id first, then by a hash of their source block, so a review survives reordering. Add {id=...} to the headings: without them a review finds a question again only by its text. quiz inspect quiz.md --review review.json shows the counts. Only quiz review writes review.json; never edit it by hand. A terminal form (--tui) is not built yet.

Brief a quiz before an agent writes it

quiz brief DIR [--purpose ...] [--scope ...] [--count N] [--minutes N] [--mix ...] [--difficulty ...] [--mode ...] [--weak-topics yes|no] [--language en|vi] [--kinds ...] [--elsewhere ...] [--request ...] [--web] [--accept-defaults] [--json] writes DIR/brief.yaml and DIR/content/ (with a README) for an agent to read. It does not write the quiz. Every flag is also a question in the form that --web serves (--no-open prints the address instead of opening the browser). --tui exits 2: the terminal form is not built yet.

Checking your file

quiz check quiz.md prints one line per finding:

quiz.md:37: error: q4 (numeric): 'value' must be a number, got 'one'
quiz.md:52: warning: q6 (single): no explanation

Errors stop the build; warnings do not. Besides structural problems, quiz check warns about weak questions: a missing or very short explanation, duplicate choices, “all/none of the above”, a correct choice much longer than every distractor, a question text that already contains the answer, and a single-choice key that favors one position (when choices are not shuffled). Exit code 0 means no errors, 1 means errors, 2 means a usage or file problem. Add --json for machine-readable output. Add --strict (also on quiz build) to treat warnings as errors, for CI: the exit code is 1 if there is any finding, and quiz build --strict writes nothing.

5. Taking a quiz

  1. Start screen: shows the number of questions, points and the time limit. If you have an attempt in progress on this device you can Resume it or Start over.
  2. Questions: all on one page, grouped by section. The numbered pills show what you answered and what you flagged: in a panel on the right of a wide screen (with the timer, “Answered 7/20”, a progress bar and a key), in a row under the title on a narrow one. After you submit, each pill also shows its result with a symbol (✓ correct, ✗ wrong, ½ partly correct, ? waiting for AI grading, – not answered), and the panel shows your score. Use Flag for review to mark a question you want to come back to. Focus (where the browser allows fullscreen) fills the screen, hides the text size, theme and export buttons and shows a clock; press Esc to leave. It only removes clutter, it does not lock anything.
  3. Hints are hidden behind a button.
  4. Instant feedback quizzes have a Check button per question. It locks the answer, shows right or wrong, the correct answer and the explanation. Pressing Enter in a one-line answer box does the same.
  5. Timer: it keeps counting if you reload the page. It warns at 5 minutes and 1 minute. When time runs out, the quiz either submits automatically or counts overtime, depending on on_timeout.
  6. Submit shows the score, a per-topic breakdown, and which questions still wait for AI review (essays and code). In a survey the button reads Finish and the page shows no score at all, only Export answers.

Keyboard and accessibility: everything works with the keyboard. In a code box, Tab indents; press Esc and then Tab to leave the box. The theme button switches between light and dark (hidden when the quiz locks its theme) and remembers your choice. A− and A+ make all text smaller or larger in five steps; the size is remembered on this device for every quiz.

Progress is saved in your browser (localStorage) for this exact quiz file. If the browser blocks storage the quiz still works, you just cannot resume.

6. Exporting and grading

After you start, Export answers is always available. It offers a download (<id>-results-<date>.json), a Copy JSON button, and a text box if the clipboard is blocked.

Then:

quiz grade results.json --quiz quiz.md

This writes results-report.md next to the results file. The report contains:

  1. A summary: score, time used, pass or fail, points still pending.
  2. A table by topic (tags).
  3. A table of all questions.
  4. Review these: every wrong or partial answer with your answer, the correct one and the explanation.
  5. Pending AI review: each essay and code answer with the prompt, the rubric and your full response, and an empty Grading block for the AI to fill in.

A quiz with ungraded questions also has an Answers section (after the question table), listing each ungraded question and the answer. A survey (mode: survey) has no score: its report is titled “Answers” and lists every question by section with its answer. Ungraded questions never appear under Pending AI review and never get a grade.

To record an AI’s scores in the report, run quiz grade results.json --json: it also writes results-grades.json, an empty template. Fill in the scores, then run quiz grade results.json --grades results-grades.json (add --json for the JSON report). The report then shows the grades and a final score that includes them. Band-scored essays show their band but do not add points.

To compare several attempts at the same quiz, run quiz stats results1.json results2.json ... --quiz quiz.md. It lists the scores by date, the percent per topic in each attempt and the change, and the three weakest topics of the latest attempt. Ungraded questions are left out of the scores; an Answers over time table follows each scale and ungraded single question across the attempts, and works for surveys too. Add --json for data.

Python grades again from the quiz source and is the authority: if the browser’s score differs, the command warns and uses its own result. It also warns if the quiz file was edited after the attempt.

With Claude Code, the /quiz skill does all of this for you: ask it to make a quiz from your notes, and later to grade the exported results.

Results and report files

Both files are plain JSON with a format and a version field, and both have a JSON Schema in src/fullmark/schemas/ (also installed with the package):

File format Written by Schema
<id>-results-<date>.json fullmark-results the page, on Export answers results-v1.schema.json
<name>-report.json fullmark-report quiz grade --json report-v1.schema.json

A response has one shape per question type (single: choice; multi: choices; truefalse: value; numeric: raw and value; short: text; blanks: blanks; fields: fields; ordering: order (item numbers in source order, so the correct answer is 0, 1, 2, …); matching: pairs (for each left item, the number of the chosen right-hand item, or null); essay: text and word_count; code: language and code; scale: value). An answered ungraded question has status: "ungraded" and earned: null; attempt.mode is quiz or survey. The tests validate every export against the schemas.

Compatibility rule: a new optional field keeps version: 1. Removing or renaming a field, or changing its meaning, bumps the version, and quiz grade refuses a version it does not know.

7. Keep a library

By default a quiz is a file: you build it, hand it out and the answers land wherever the browser saves them. The library is the other way to work. It is one folder on a drive you choose, under git, that owns your own quizzes and files every result next to the version you answered. Core commands stay as they are and need no library. The library commands are init, status, register, list, take, share, path, grade, stats, rename and rm.

Set it up: init

quiz library init D:\quizzes

Add a quiz: register

quiz library register quiz.md checks the file as quiz check does (it refuses on errors; --strict also refuses on warnings; --review review.json applies a review), then copies the source, its images and its brief.yaml into the library and commits. No HTML is stored. The original file can then be deleted: a scratch folder is fine.

Take a quiz: take

quiz library take NAME builds the page on the spot with the installed fullmark, serves it on 127.0.0.1 and opens the browser. Submit (or Finish) files the result as quizzes/<id>/results/<date-time>.json in the library and commits. An attempt in progress is saved in the library too, so closing the tab or the terminal and running take again offers Resume. If the original source still exists and has changed since you registered it, take warns and serves the registered version; run register --update to adopt the change. --no-open prints the address instead of opening the browser, --timeout SECONDS stops waiting (default: no limit).

See what you have: list and status

Share and edit: share

Grade and follow progress: grade and stats

Rename and delete: rename and rm

Git and backups

fullmark commits after each init, register, take, grade, rename and rm when the library folder is its own repository, and it never pushes. The history protects against mistakes, not against losing the drive; status says “no remote” until you add one or keep the folder in a synced folder. Results hold your answers and the snapshots hold the answer keys, so any remote must be private. Snapshots are read-only, and status reports an edited one; restoring is your step (git restore). To push automatically, add a post-commit hook to the library’s .git/hooks: that hook is yours, not fullmark’s.

Results and snapshots are plain files. quiz library path NAME prints a quiz’s folder, and core quiz grade and quiz stats still work on the files in it; quiz library grade and stats just pick the matching snapshot for you.

8. Command reference

Command What it does
quiz check FILE [--json] [--strict] [--review REVIEW] Validate a quiz file; --strict fails on warnings too; --review also holds it to a review.json
quiz build FILE [-o OUT] [--open] [--strict] Validate, then write the HTML; --strict fails on warnings and writes nothing
quiz watch FILE [-o OUT] [--open] [--serve] Rebuild whenever the quiz or its assets/ change; keeps the last good build when a save has errors. Reload the page after each rebuild, or add --serve to serve it on 127.0.0.1 and reload the browser automatically (only the served copy gets the reload code; the file you hand out is unchanged)
quiz serve FILE [--out RESULTS] [--once] [--timeout SECONDS] [--open] [--port N] [--autosave PROGRESS] [--json] Serve the quiz page on 127.0.0.1 and wait for the answers: Finish or Submit sends the results to the server, which checks them and writes the JSON file, then exits. --autosave keeps an attempt in progress so it resumes after the server or browser closes
quiz review FILE [--out REVIEW] [--since REVIEW] [--once] [--open] [--timeout SECONDS] [--port N] [--json] Serve a review form (keep, fix or drop each question) and write review.json when the learner presses Finish
quiz brief DIR [--purpose P] [--scope S] [--count N] [--mix M] [--web] [--accept-defaults] [--json] ... Write brief.yaml and content/ for a quiz an agent will write; exit 3 when the purpose or scope is missing and no form was asked for
quiz grade RESULTS [--quiz FILE] [-o OUT] [--grades GRADES] [--json] Re-grade and write the Markdown report; --json writes *-report.json and an empty grades template instead; --grades folds a filled-in grades file into the report
quiz stats RESULTS... [--quiz FILE] [--json] Compare several attempts at one quiz: scores by date, percent per topic, the weakest topics
quiz inspect FILE [--json] [--review REVIEW] Summarize a quiz: questions and points by type, section and tag, and where the single-choice answers sit. Use it to check coverage before handing a quiz out
quiz new TEMPLATE [PATH] Copy a template (never overwrites)
quiz templates List the templates
quiz guide Print the authoring reference
quiz agent-guide Print the instructions for AI agents (the same text as AGENTS.md, shipped in the package)
quiz library init, status, register, list, take, share and path: keep quizzes and results in one folder under git. take NAME serves a quiz and files the result in the library; share NAME writes a standalone page, or with --source an editable folder; path [NAME] prints the library or a quiz folder (see section 7). status and list print colored tables in a terminal; --plain or NO_COLOR gives plain text, --json is for scripts

9. How it is built

Part Library
Markdown parsing markdown-it-py with mdit-py-plugins
Front matter PyYAML (safe_load only)
Models and validation pydantic v2
Code highlighting Pygments with custom pastel styles
HTML assembly Jinja2
Math KaTeX 0.16.47, vendored
Browser runtime plain JavaScript and CSS, no framework, no build step

Source layout (under src/fullmark/):

The design decisions are in SPEC.md.

10. Testing

cd <path to this repo>
uv run pytest -q                       # everything (about 30 seconds)
uv run pytest -q --ignore=tests/e2e    # skip the browser tests

The tests cover validation (one fixture per error), the grader (over 100 golden cases run through both Python and JavaScript), the build, the report, the examples, and real-browser end-to-end flows on file:// pages. They also check that the page makes no network requests and that text colors meet contrast limits in both themes.

11. Limits and tips