Split technical questions into per-class decks

This commit is contained in:
2026-05-22 15:05:46 +02:00
parent 92d9797b60
commit 0d635a8587
5 changed files with 167 additions and 55 deletions
+11 -5
View File
@@ -43,7 +43,9 @@ BNetzA ZIP ──[Stage 1: amateurfunk_fetch.py]──► data/<slug>/
└── manifest.json └── manifest.json
data/ ──[Stage 2: amateurfunk_anki.py]──► anki/ data/ ──[Stage 2: amateurfunk_anki.py]──► anki/
├── amateurfunk-technische-kenntnisse.apkg ├── amateurfunk-technische-kenntnisse-n.apkg
├── amateurfunk-technische-kenntnisse-e.apkg
├── amateurfunk-technische-kenntnisse-a.apkg
├── amateurfunk-betriebliche-kenntnisse.apkg ├── amateurfunk-betriebliche-kenntnisse.apkg
└── amateurfunk-kenntnisse-von-vorschriften.apkg └── amateurfunk-kenntnisse-von-vorschriften.apkg
``` ```
@@ -66,9 +68,12 @@ data/ ──[Stage 2: amateurfunk_anki.py]──► anki/
1. Read the latest edition from `data/` (following 1. Read the latest edition from `data/` (following
`manifest-latest.json` to a per-edition directory). `manifest-latest.json` to a per-edition directory).
2. Split the catalog into three categories — one per top-level 2. Split the catalog into five categories. Betriebliche and
Prüfungsteil (Technische / Betriebliche / Vorschriften). The Vorschriften get one deck each (shared across every candidate).
license-class axis is mapped into tags, not separate packages. Technische is additionally fanned out per license class into three
decks (N / E / A) using a strict equality split on the question's
`class` field. The `klasse-N|E|A` tag is still emitted on every
note for inside-Anki filtering.
3. Render every question as an Anki note: shuffled A/B/C/D choices on 3. Render every question as an Anki note: shuffled A/B/C/D choices on
the front, the displayed position of the correct answer on the the front, the displayed position of the correct answer on the
back. Inline `$...$` LaTeX is converted to MathJax `\(...\)` back. Inline `$...$` LaTeX is converted to MathJax `\(...\)`
@@ -101,7 +106,8 @@ SVG dark-mode handling, schema choices) live in `DESIGN.md` §7.
- Start from `DESIGN.md` — it has the JSON schema, the question/answer - Start from `DESIGN.md` — it has the JSON schema, the question/answer
conventions (answer A is always correct upstream → consumers shuffle conventions (answer A is always correct upstream → consumers shuffle
before display), the LaTeX-in-questions caveat, the exam-structure before display), the LaTeX-in-questions caveat, the exam-structure
rationale for the three Anki packages, and per-stage design notes. rationale for the Anki package layout (Betriebliche, Vorschriften,
and Technische split per license class), and per-stage design notes.
- Do not invent new download URLs; the ones in `DESIGN.md` were - Do not invent new download URLs; the ones in `DESIGN.md` were
verified against the live BNetzA site. verified against the live BNetzA site.
- When BNetzA publishes a new edition, expect a new - When BNetzA publishes a new edition, expect a new
+45 -21
View File
@@ -491,28 +491,43 @@ output artifacts.
``` ```
anki/ anki/
amateurfunk-technische-kenntnisse.apkg (~2.5 MB, 1374 cards) amateurfunk-technische-kenntnisse-n.apkg (195 cards)
amateurfunk-betriebliche-kenntnisse.apkg (~130 KB, 172 cards) amateurfunk-technische-kenntnisse-e.apkg (463 cards)
amateurfunk-kenntnisse-von-vorschriften.apkg (~75 KB, 204 cards) amateurfunk-technische-kenntnisse-a.apkg (716 cards)
amateurfunk-betriebliche-kenntnisse.apkg (172 cards)
amateurfunk-kenntnisse-von-vorschriften.apkg (204 cards)
``` ```
One `.apkg` per top-level Prüfungsteil. The card counts match the Five `.apkg` files. Betriebliche and Vorschriften are shared across
study pools from §3 axis 1: Technische = 1374 (cumulative N+E+A); every candidate (class-1-only in the data per §3 axis 2) and stay as
Betriebliche = 172 (shared across all candidates); Vorschriften = 204 one deck each. Technische is fanned out per license class using a
(also shared). License class is conveyed via tags **strict equality split** on the question's `class` field — class-1
(`klasse-N` / `klasse-E` / `klasse-A`), not separate packages — questions land in the N deck only, class-2 in E only, class-3 in A
Anki users browse decks by topic and filter by class. only. The card counts therefore equal the new-at-this-class slices
from §3, not the cumulative study pools: a candidate studying for
class E imports Technische-N + Technische-E + Betriebliche +
Vorschriften.
The Technische deck names use Anki's `::` hierarchy separator
(`Amateurfunk::Technische Kenntnisse::N`) so the three decks render
as children of a shared parent in Anki's deck browser. The
`klasse-N` / `klasse-E` / `klasse-A` tag is still emitted on every
note — redundant within each Technische deck but useful in Betr/Vor
for in-Anki filtering, and harmless besides.
### Steps ### Steps
1. **Load** the latest catalog: follow `manifest-latest.json` to a 1. **Load** the latest catalog: follow `manifest-latest.json` to a
per-edition directory, read the catalog JSON, the per-edition per-edition directory, read the catalog JSON, the per-edition
`manifest.json`, and index the `svgs/` folder. `manifest.json`, and index the `svgs/` folder.
2. **Categorize** the question tree into three `Category` objects, 2. **Categorize** the question tree into five `Category` objects.
one per top-level Prüfungsteil. Each category carries the flat Betriebliche and Vorschriften get one each. Technische is
list of every question that lives anywhere under it, along with additionally split into three sub-categories (one per license
each question's path through the section tree (used for the card class) via a strict equality match on the question's `class`
breadcrumb and the path tags). field. Each category carries the flat list of every question that
lives anywhere under it, along with each question's path through
the section tree (used for the card breadcrumb and the path
tags).
3. **For each category, render every question** as an Anki note: 3. **For each category, render every question** as an Anki note:
- Compute a stable per-question seed from `--seed` + question - Compute a stable per-question seed from `--seed` + question
number; shuffle the A/B/C/D choices using it. The displayed number; shuffle the A/B/C/D choices using it. The displayed
@@ -594,13 +609,19 @@ byte-identical sha256 on each `.apkg`. Verified during review.
- **Slug for filenames.** `slugify()` maps German umlauts and ß - **Slug for filenames.** `slugify()` maps German umlauts and ß
to ASCII digraphs before NFKD-normalizing the rest, then keeps to ASCII digraphs before NFKD-normalizing the rest, then keeps
only `[a-z0-9-]`. Yields readable, sortable filenames only `[a-z0-9-]`. Yields readable, sortable filenames
(`amateurfunk-technische-kenntnisse.apkg`). (`amateurfunk-technische-kenntnisse-n.apkg`). The three
Technische decks append the class letter (`-n`, `-e`, `-a`)
to the shared base slug; Betriebliche and Vorschriften use the
base slug as-is.
- **Tags.** Each note carries `klasse-N|E|A` plus - **Tags.** Each note carries `klasse-N|E|A` plus
`pfad-<slugified-section>` for every section level below the `pfad-<slugified-section>` for every section level below the
top-level Prüfungsteil. Per-question number tags are top-level Prüfungsteil. The `klasse-*` tag is redundant within
deliberately *not* emitted — they would create ~1750 singletons the three Technische decks (every card already shares one class)
in Anki's tag tree, and the number is already in the dedicated but is still emitted there for uniformity, and remains the
`Number` field for search. primary filter axis inside the Betriebliche/Vorschriften decks.
Per-question number tags are deliberately *not* emitted — they
would create ~1750 singletons in Anki's tag tree, and the number
is already in the dedicated `Number` field for search.
- **Breadcrumb consistency.** The visible card breadcrumb and the - **Breadcrumb consistency.** The visible card breadcrumb and the
stored `Path` field both go through one `display_path()` stored `Path` field both go through one `display_path()`
helper, so they never drift. The boilerplate helper, so they never drift. The boilerplate
@@ -618,8 +639,11 @@ Network-free, fixture-driven. Real Anki is NOT required to
verify the output — tests open the `.apkg` ZIP directly, parse verify the output — tests open the `.apkg` ZIP directly, parse
the embedded SQLite, and check structural invariants. the embedded SQLite, and check structural invariants.
- Top-level structure: one `.apkg` per Prüfungsteil; expected - Top-level structure: five `.apkg` files (Betriebliche +
filename slugs. Vorschriften + Technische×{N,E,A}); expected filename slugs.
- Technische class partition: each Technische deck contains
exactly the questions whose `class` field matches that letter,
and no others.
- Note count and field shape per category. - Note count and field shape per category.
- Tag mapping: a class-1 question carries `klasse-N`, no - Tag mapping: a class-1 question carries `klasse-N`, no
`nummer-*` tags. `nummer-*` tags.
+6 -3
View File
@@ -13,9 +13,12 @@ make test # run both test suites
make clean # remove data/ and anki/ make clean # remove data/ and anki/
``` ```
Output: three `.apkg` files under `anki/`, one per Prüfungsteil Output: five `.apkg` files under `anki/`. Betriebliche and
(Technische / Betriebliche / Vorschriften). Import them into Anki — Vorschriften get one deck each (shared across all license classes);
re-importing a newer build preserves your review history. Technische is split per class into three decks (N / E / A) following
the catalog's `class` field. A class-A candidate who wants every
Technische question imports all three Technische decks. Re-importing
a newer build preserves your review history.
## Exam question source ## Exam question source
+66 -20
View File
@@ -7,10 +7,10 @@ script does:
1. Read the per-edition directory written by `amateurfunk_fetch.py` 1. Read the per-edition directory written by `amateurfunk_fetch.py`
(the JSON catalog, the `svgs/` folder, and the per-edition (the JSON catalog, the `svgs/` folder, and the per-edition
`manifest.json`). `manifest.json`).
2. Split the catalog into three categories — one per top-level 2. Split the catalog into five categories. Betriebliche and
Prüfungsteil: Technische / Betriebliche / Vorschriften. (The Vorschriften get one each. Technische is additionally fanned
license-class axis is mapped into tags, not into separate out per license class into three decks (N / E / A) via a
packages.) strict equality split on the question's `class` field.
3. For each category, render every question as an Anki note: front 3. For each category, render every question as an Anki note: front
shows the (shuffled) A/B/C/D choices, back names the displayed shows the (shuffled) A/B/C/D choices, back names the displayed
position of the correct answer. position of the correct answer.
@@ -20,8 +20,8 @@ script does:
The script is intentionally a single file with stdlib only. Readability The script is intentionally a single file with stdlib only. Readability
beats cleverness here — most of the bytes below are docstrings and beats cleverness here — most of the bytes below are docstrings and
comments. Performance is a non-goal (we build three small decks once comments. Performance is a non-goal (we build a handful of small
per upstream edition). decks once per upstream edition).
""" """
import argparse import argparse
@@ -75,6 +75,16 @@ TOP_LEVEL_PREFIX = "Prüfungsfragen im Prüfungsteil: "
# search for. # search for.
CLASS_TAGS = {"1": "N", "2": "E", "3": "A"} CLASS_TAGS = {"1": "N", "2": "E", "3": "A"}
# Ordered class digits used when fanning Technische Kenntnisse out
# into one deck per license class. Order controls the on-disk file
# ordering and the Anki deck-tree ordering.
CLASS_ORDER = [("1", "N"), ("2", "E"), ("3", "A")]
# Short title of the top-level Prüfungsteil that gets fanned out
# per license class. The other two (Betriebliche, Vorschriften) are
# shared across all candidates and stay as a single deck each.
TECHNISCHE_SHORT_TITLE = "Technische Kenntnisse"
# Fallback build epoch if neither the manifest nor `--epoch` supplies # Fallback build epoch if neither the manifest nor `--epoch` supplies
# one. Picked as 0 so missing-metadata builds are still deterministic # one. Picked as 0 so missing-metadata builds are still deterministic
# and obviously wrong (timestamps would all show 1970). # and obviously wrong (timestamps would all show 1970).
@@ -164,12 +174,18 @@ def load_latest_catalog(data_dir):
def collect_categories(catalog): def collect_categories(catalog):
"""Split the catalog into one `Category` per top-level section. """Split the catalog into one `Category` per output `.apkg`.
Per DESIGN.md §3 axis 1, the catalog's three top-level sections Per DESIGN.md §3 axis 1 the catalog has three top-level sections
are exactly the three Prüfungsteile. We walk each subtree and (the three Prüfungsteile). Technische Kenntnisse is additionally
flatten every question it contains, recording the section path so fanned out per license class (N/E/A) — see DESIGN.md §7 "Output
cards can show a breadcrumb and we can emit path-shaped tags. layout". Betriebliche and Vorschriften are class-1-only in the
data but apply to every candidate, so they stay as one category
each.
For every category we flatten the subtree into a list of
`QuestionItem`s carrying the section path, used downstream for
the breadcrumb and the `pfad-*` tags.
""" """
sections = catalog.get("sections") sections = catalog.get("sections")
if not isinstance(sections, list): if not isinstance(sections, list):
@@ -181,17 +197,47 @@ def collect_categories(catalog):
questions = [] questions = []
_collect_questions(section, (title,), questions) _collect_questions(section, (title,), questions)
short_title = _short_category_title(title) short_title = _short_category_title(title)
categories.append( if short_title == TECHNISCHE_SHORT_TITLE:
Category( categories.extend(_split_by_class(title, short_title, questions))
title=title, else:
short_title=short_title, categories.append(
slug=slugify(short_title), Category(
questions=questions, title=title,
short_title=short_title,
slug=slugify(short_title),
questions=questions,
)
) )
)
return categories return categories
def _split_by_class(title, short_title, questions):
"""Fan one Prüfungsteil out into one `Category` per license class.
Strict equality split on the question's `class` field: a class-1
question lands in the N deck only, class-2 in E only, class-3 in
A only. This matches the catalog's `class` field as written, not
the cumulative study pool (a class-A candidate who wants every
Technische question imports all three decks).
The short title uses Anki's `::` deck-hierarchy separator so the
three decks render as children of a shared parent in Anki's deck
browser (e.g. `Amateurfunk::Technische Kenntnisse::N`).
"""
base_slug = slugify(short_title)
for digit, letter in CLASS_ORDER:
subset = [
item for item in questions
if str(item.question.get("class")) == digit
]
yield Category(
title=f"{title} — Klasse {letter}",
short_title=f"{short_title}::{letter}",
slug=f"{base_slug}-{letter.lower()}",
questions=subset,
)
def _collect_questions(section, path, out): def _collect_questions(section, path, out):
"""Recurse into a section node and append every question to `out`. """Recurse into a section node and append every question to `out`.
@@ -1203,8 +1249,8 @@ def build_all(data_dir, out_dir, seed, override_epoch=None):
"""Build every category's `.apkg` and return their result dicts. """Build every category's `.apkg` and return their result dicts.
Loads the latest fetched catalog, picks a build epoch, then walks Loads the latest fetched catalog, picks a build epoch, then walks
the three top-level categories writing one `.apkg` each. Raises every category writing one `.apkg` each. Raises `AnkiBuildError`
`AnkiBuildError` on configuration / catalog problems. on configuration / catalog problems.
""" """
edition_dir, manifest, catalog = load_latest_catalog(data_dir) edition_dir, manifest, catalog = load_latest_catalog(data_dir)
build_epoch = build_epoch_from_manifest(manifest, override_epoch) build_epoch = build_epoch_from_manifest(manifest, override_epoch)
+39 -6
View File
@@ -32,6 +32,7 @@ def make_catalog():
"questions": [ "questions": [
question("NA101", "1", "Was ist A?", "NA101_q"), question("NA101", "1", "Was ist A?", "NA101_q"),
question("EA202", "2", "Was ist E?", None), question("EA202", "2", "Was ist E?", None),
question("AA303", "3", "Was ist A-Klasse?", None),
], ],
} }
], ],
@@ -115,7 +116,7 @@ class TestAnkiBuild(unittest.TestCase):
self.data_dir = make_fetched_data(self.root) self.data_dir = make_fetched_data(self.root)
self.out_dir = self.root / "anki" self.out_dir = self.root / "anki"
def test_builds_one_apkg_per_top_level_category(self): def test_builds_one_apkg_per_category(self):
results = aa.build_all(self.data_dir, self.out_dir, seed="test-seed") results = aa.build_all(self.data_dir, self.out_dir, seed="test-seed")
paths = sorted(path.name for path in self.out_dir.glob("*.apkg")) paths = sorted(path.name for path in self.out_dir.glob("*.apkg"))
self.assertEqual( self.assertEqual(
@@ -123,14 +124,46 @@ class TestAnkiBuild(unittest.TestCase):
[ [
"amateurfunk-betriebliche-kenntnisse.apkg", "amateurfunk-betriebliche-kenntnisse.apkg",
"amateurfunk-kenntnisse-von-vorschriften.apkg", "amateurfunk-kenntnisse-von-vorschriften.apkg",
"amateurfunk-technische-kenntnisse.apkg", "amateurfunk-technische-kenntnisse-a.apkg",
"amateurfunk-technische-kenntnisse-e.apkg",
"amateurfunk-technische-kenntnisse-n.apkg",
], ],
) )
self.assertEqual([r["questions"] for r in results], [2, 1, 1]) by_deck = {r["deck"]: r["questions"] for r in results}
self.assertEqual(
by_deck,
{
"Amateurfunk::Technische Kenntnisse::N": 1,
"Amateurfunk::Technische Kenntnisse::E": 1,
"Amateurfunk::Technische Kenntnisse::A": 1,
"Amateurfunk::Betriebliche Kenntnisse": 1,
"Amateurfunk::Kenntnisse von Vorschriften": 1,
},
)
def test_technische_decks_partition_strictly_by_class_field(self):
aa.build_all(self.data_dir, self.out_dir, seed="test-seed")
per_class_numbers = {}
for letter in ("n", "e", "a"):
apkg = self.out_dir / f"amateurfunk-technische-kenntnisse-{letter}.apkg"
db_path, _media, _names = extract_collection(apkg, self.root / letter)
conn = sqlite3.connect(db_path)
try:
numbers = [
row[0].split(aa.FIELD_SEP, 1)[0]
for row in conn.execute("select flds from notes")
]
finally:
conn.close()
per_class_numbers[letter.upper()] = sorted(numbers)
self.assertEqual(
per_class_numbers,
{"N": ["NA101"], "E": ["EA202"], "A": ["AA303"]},
)
def test_apkg_contains_notes_cards_and_media(self): def test_apkg_contains_notes_cards_and_media(self):
aa.build_all(self.data_dir, self.out_dir, seed="test-seed") aa.build_all(self.data_dir, self.out_dir, seed="test-seed")
apkg = self.out_dir / "amateurfunk-technische-kenntnisse.apkg" apkg = self.out_dir / "amateurfunk-technische-kenntnisse-n.apkg"
db_path, media, names = extract_collection(apkg, self.root) db_path, media, names = extract_collection(apkg, self.root)
self.assertIn("collection.anki2", names) self.assertIn("collection.anki2", names)
@@ -150,8 +183,8 @@ class TestAnkiBuild(unittest.TestCase):
try: try:
note_count = conn.execute("select count(*) from notes").fetchone()[0] note_count = conn.execute("select count(*) from notes").fetchone()[0]
card_count = conn.execute("select count(*) from cards").fetchone()[0] card_count = conn.execute("select count(*) from cards").fetchone()[0]
self.assertEqual(note_count, 2) self.assertEqual(note_count, 1)
self.assertEqual(card_count, 2) self.assertEqual(card_count, 1)
fields = [row[0] for row in conn.execute("select flds from notes")] fields = [row[0] for row in conn.execute("select flds from notes")]
finally: finally:
conn.close() conn.close()