
Practice Paper
- 1 installs
- 1 repo stars
- Updated May 2, 2026
- pattyboi101/open-book-exam-prep
Generates mock open-book exam papers from a course binder, lets the user attempt them in .docx, and marks attempts against the course's own rubric.
About
Reads a course's course.yaml and templates to generate short mock open-book exam papers, then marks .docx attempts against the course rubric. A developer or student uses it to create and grade practice exam papers for a specific course.
- Generates and marks papers from course.yaml, rubric and templates
- Produces attempts in .docx and grades against criteria/weights/bands
Practice Paper by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,983 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Jul 8, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pattyboi101/open-book-exam-prep --skill practice-paperAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 1 |
| Last updated | May 2, 2026 |
| Repository | pattyboi101/open-book-exam-prep ↗ |
What it does
Generates mock open-book exam papers from a course binder, lets the user attempt them in .docx, and marks attempts against the course's own rubric.
Files
Practice Paper Skill
Generates short mock open-book exam papers from the course binder, lets the user attempt them in .docx, and marks attempts against the course's own rubric — all in a single session per paper.
The skill reads everything from the course's course.yaml and templates/. It works with any course that follows the schema — modules, rubric criteria/weights/bands, paper config, and question-style template are all course-specific.
Step 0: Resolve the course
If a course path is given as the first arg, use it. Otherwise check the current working directory: if it has a course.yaml, that's the course. If neither — ask the user "which course?" and stop.
# load course config (just to confirm it's valid; you'll re-read fields below)
python -c "from pipeline.course import load_course; c = load_course('<course-path>'); print(c.name, c.module_codes)"Throughout this skill, <course> means the resolved course root. Read the following from <course>/course.yaml:
course.name— for the paper title blockmodules— list of {code, display_name}; the user picks one with the trigger argrubric.criteria— list of {name, weight, descriptor}; weights sum torubric.scalerubric.bands(optional) — list of {name, min}rubric.scale(default 100)paper.short_form(default 4)paper.essay(default 1)paper.target_minutes(default 60)paper.no_repeat_within(default 2)
Step 1: Detect mode
| Trigger | Mode |
|---|---|
/practice-paper <course-path> <MODULE_CODE> | generate |
/practice-paper <module-code> from inside course dir | generate |
/practice-paper <course-path> mark <paper-path> | mark for that path |
/practice-paper mark <paper-path> | mark for that path |
"mark it" / "ready to mark" / "grade this" / similar AND _history.md has a pending row | mark the most-recent pending paper |
| Anything else | Ask the user to clarify |
If multiple modules have pending papers and the user says "mark it" without specifying, list them and ask which.
---
GENERATE MODE
Step 2: Pick lectures
1. Read <course>/papers/_history.md (create empty if missing). Identify lectures used as primary source in the most recent paper.no_repeat_within papers for the chosen module. 2. List candidate lectures: <course>/vault/lectures/{module}_L*.md. 3. Pick paper.short_form + paper.essay lectures total:
paper.essayof them get the essay question(s); essay lectures may
pull from 1 adjacent lecture
paper.short_formget one short-form question each
4. Prefer fresh lectures: avoid any used in the recent paper.no_repeat_within papers. Allow rare repeats only if >75% of lectures have already been used recently.
Step 3: Read source material
- Read each chosen lecture vault
.mdfile in full (<course>/vault/lectures/{module}_L{NN}_*.md) - Read the question-style guide:
<course>/templates/question_style.md
if it exists, else pipeline/templates/question_style.md from the installed package
- Reference the rubric:
course.yamlrubric.criteria(each criterion's
name + weight + descriptor)
Step 4: Generate questions
Match the question patterns in the question-style guide. Mix freely; pick the pattern that fits each lecture's content best. Never write generic "Discuss X" or "Describe Y".
Per-question targets (from the style guide):
| Type | Lecture anchor | Word target | Time |
|---|---|---|---|
| short-form | 1 lecture | 150-300 | 5-10 min |
| essay | 1-2 lectures | 500-800 | 25-30 min |
Total paper time: ~paper.target_minutes.
Step 5: Write outline answers
For each question, write outline answer per the style guide's "Outline Answer Format". The rubric guidance block in each outline must reflect the actual rubric.criteria from course.yaml — name, weight, what top-tier looks like for that criterion.
Reference scheme: sections by heading (§Section name), NEVER absolute page numbers — pages shift on rebuild.
Step 6: Render the paper docx
Write the question list as JSON:
{
"title": "<course.name> — <module-code> Practice Paper N",
"module": "<module-code>",
"date": "YYYY-MM-DD",
"questions": [
{"number": "Q1", "type": "short", "primary_lecture": "<MODULE> L05", "text": "..."},
{"number": "Q2", "type": "short", "primary_lecture": "<MODULE> L09", "text": "..."},
{"number": "Q3", "type": "essay", "primary_lecture": "<MODULE> L03", "text": "..."}
]
}Save to /tmp/paper-content.json, then render with the repo's renderer:
python <repo-root>/skills/practice-paper/render_paper.py \
--json /tmp/paper-content.json \
--out <course>/papers/YYYY-MM-DD-<MODULE>-paper-N.docxThe renderer creates: title page → instructions → each question with "Your answer:" prompt and ~10 (short) or ~25 (essay) blank paragraphs of typing space.
Step 7: Write key.md sidecar
Write <course>/papers/YYYY-MM-DD-<MODULE>-paper-N-key.md with the outline answers from Step 5 (one section per question).
Step 8: Update history
Append to <course>/papers/_history.md. Create with header if new:
# Practice paper history
| Date | File | Module | Primary lectures | Status | Mark |
|------|------|--------|------------------|--------|------|
| YYYY-MM-DD | <filename>.docx | <MODULE> | L03 (essay), L05, L09 | pending | — |Step 9: Hand off to user
Tell the user:
- Paper saved at
<course>/papers/<filename>.docx - Display the questions inline in chat (so they can read them straight away)
- Instructions: open the docx, type answers under each "Your answer:" prompt, save in place
- When done, say "mark it" — same conversation, no
/clearneeded
---
MARK MODE
Step 1: Find the paper
If a path was passed: use it.
Otherwise: read <course>/papers/_history.md, find the most recent row with Status = pending. If multiple modules pending and "mark it" is ambiguous, list them and ask the user.
You'll need three things:
<course>/papers/<paper>.docx— the user's filled attempt<course>/papers/<paper>-key.md— outline answers + rubric guidance- The history row (to update at the end)
Step 2: Extract the attempt
python <repo-root>/skills/practice-paper/extract_attempt.py \
--docx <course>/papers/<paper>.docx \
--out /tmp/attempt.jsonProduces JSON: [{number, type, primary_lecture, question_text, answer}, ...].
If extraction returns empty answers but the docx is non-trivial in size, fall back to reading the docx contents directly via unzip -p ... word/document.xml and parsing.
If a question has no attempt: mark 0/<scale> with note "no attempt"; do not penalise other questions.
Step 3: Read the key + rubric prompt
- Read
<paper>-key.mdto get outline answers + rubric guidance - Read the marking-prompt scaffold:
<course>/templates/rubric_prompt.md if it exists, else pipeline/templates/rubric_prompt.md from the installed package
- Splice in the actual
rubric.criteriafromcourse.yaml
Step 4: Mark each question
Score each rubric criterion separately (each on the 0–rubric.scale range). Per-question total:
weighted_total = round(
(score_1 * weight_1 + score_2 * weight_2 + ...) / sum_of_weights
)If rubric.bands is defined, append band labels via the band whose min the mark meets or exceeds.
Per-question output format (one block per criterion, dynamically generated from rubric.criteria):
### Q[N] — [type] — Mark: [weighted_total]/[scale] [— band, if defined]
**[Criterion 1 name] ([criterion 1 weight]%):** [score]/[scale] [— band]
- Good: [what they got right against this criterion's descriptor]
- Gap: [what was missing or weak]
- To push higher: [specific step that would lift the next attempt]
[repeat for each criterion in course.yaml rubric.criteria]
[1-2 sentence overall comment]Step 5: Overall summary
## Overall — N% [— band]
**Strongest:** Q[X] — [why]
**Weakest:** Q[Y] — [why]
**Top focus for next paper:** [1-2 specific things]Where overall is the unweighted mean of per-question weighted totals (each question counts equally).
Step 6: Update history
Edit _history.md: change the row's Status to marked and Mark to the overall %.
---
EDGE CASES
| Case | Handling |
|---|---|
No _history.md | Create with header on first generate |
| Empty answer for a question | Mark 0/<scale> with "no attempt", continue marking others |
| Filled docx not found at mark time | Ask user to confirm path or paste attempt as text in chat |
| Multiple pending papers, "mark it" ambiguous | List pending rows, ask which |
| User attempts only some questions | Mark what's there, flag unattempted |
| All lectures recently used | Allow repeats, prefer least-recently-used |
rubric.bands not defined | Output numeric marks only, no band labels |
rubric.scale ≠ 100 | Report marks out of rubric.scale (e.g. /50, /20); compute overall % as (mean / scale) * 100 for the summary line |
PARAMETER CONVENTIONS
- N in filename: paper number for that module-date combo. If
YYYY-MM-DD-<MODULE>-paper-1.docx already exists, use paper-2. Don't overwrite.
- Module names: use the canonical code from
course.yaml
modules[].code. Don't accept variants — the user's input must match one of the configured codes.
- Date format:
YYYY-MM-DD. Use today's date.
CALIBRATION CAVEAT
Real examiners often mark relative to the cohort distribution. This skill marks against the rubric in absolute terms — treat marks as relative-to-self over time, not absolute predictions of real-exam outcomes.
"""Extract typed answers from a filled paper.docx.
Splits the document on question headings (e.g. "Q1 — short") and pulls the
text typed after each "Your answer:" prompt up to the next question heading
or end of doc.
"""
import argparse
import json
import re
from pathlib import Path
from docx import Document
QUESTION_HEADING_RE = re.compile(
r"^Q(\d+)\s*[—–-]\s*(short|essay)\s*$",
re.IGNORECASE,
)
def extract(docx_path):
doc = Document(docx_path)
questions = []
current = None
state = "before"
buf = []
def flush():
if current is None:
return
current["answer"] = "\n".join(p for p in buf if p.strip()).strip()
questions.append(current)
for para in doc.paragraphs:
text = para.text.strip()
m = QUESTION_HEADING_RE.match(text)
if m:
flush()
current = {
"number": f"Q{m.group(1)}",
"type": m.group(2).lower(),
"primary_lecture": "",
"question_text": "",
"answer": "",
}
buf = []
state = "in_question"
continue
if current is None:
continue
if state == "in_question":
if text.startswith("Primary lecture:"):
current["primary_lecture"] = text.replace(
"Primary lecture:", ""
).strip()
elif text == "Your answer:":
current["question_text"] = " ".join(buf).strip()
buf = []
state = "in_answer"
elif text:
buf.append(text)
elif state == "in_answer":
if text:
buf.append(text)
flush()
return {"questions": questions}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--docx", required=True, help="Filled paper docx path")
parser.add_argument("--out", required=True, help="Output attempt JSON path")
args = parser.parse_args()
result = extract(args.docx)
Path(args.out).write_text(json.dumps(result, indent=2))
print(
f"Wrote {args.out} with {len(result['questions'])} questions "
f"({sum(1 for q in result['questions'] if q['answer']) } attempted)"
)
if __name__ == "__main__":
main()
"""Render a JSON paper spec to a .docx file with answer space."""
import argparse
import json
from pathlib import Path
from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.shared import Pt
def render(spec, out_path):
doc = Document()
title = doc.add_heading(spec["title"], level=0)
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
meta = doc.add_paragraph()
meta.alignment = WD_ALIGN_PARAGRAPH.CENTER
meta_run = meta.add_run(f"Module: {spec['module']} Date: {spec['date']}")
meta_run.italic = True
instructions = doc.add_paragraph(
'Open-book exam practice. Type your answers in the space provided '
'after each question. Save the file in place. When done, return to '
'the chat and say "mark it".'
)
instructions.paragraph_format.space_after = Pt(12)
doc.add_page_break()
questions = spec["questions"]
for i, q in enumerate(questions):
heading_text = f"{q['number']} — {q['type']}"
doc.add_heading(heading_text, level=1)
meta_p = doc.add_paragraph()
run = meta_p.add_run(f"Primary lecture: {q['primary_lecture']}")
run.italic = True
run.font.size = Pt(10)
doc.add_paragraph(q["text"])
answer_h = doc.add_paragraph()
answer_run = answer_h.add_run("Your answer:")
answer_run.bold = True
blank_count = 10 if q["type"] == "short" else 25
for _ in range(blank_count):
doc.add_paragraph("")
if i < len(questions) - 1:
doc.add_page_break()
doc.save(out_path)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--json", required=True, help="Paper spec JSON path")
parser.add_argument("--out", required=True, help="Output .docx path")
args = parser.parse_args()
spec = json.loads(Path(args.json).read_text())
render(spec, args.out)
print(f"Wrote {args.out} ({len(spec['questions'])} questions)")
if __name__ == "__main__":
main()