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
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-kenntnisse-von-vorschriften.apkg
```
@@ -66,9 +68,12 @@ data/ ──[Stage 2: amateurfunk_anki.py]──► anki/
1. Read the latest edition from `data/` (following
`manifest-latest.json` to a per-edition directory).
2. Split the catalog into three categories — one per top-level
Prüfungsteil (Technische / Betriebliche / Vorschriften). The
license-class axis is mapped into tags, not separate packages.
2. Split the catalog into five categories. Betriebliche and
Vorschriften get one deck each (shared across every candidate).
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
the front, the displayed position of the correct answer on the
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
conventions (answer A is always correct upstream → consumers shuffle
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
verified against the live BNetzA site.
- When BNetzA publishes a new edition, expect a new
+45 -21
View File
@@ -491,28 +491,43 @@ output artifacts.
```
anki/
amateurfunk-technische-kenntnisse.apkg (~2.5 MB, 1374 cards)
amateurfunk-betriebliche-kenntnisse.apkg (~130 KB, 172 cards)
amateurfunk-kenntnisse-von-vorschriften.apkg (~75 KB, 204 cards)
amateurfunk-technische-kenntnisse-n.apkg (195 cards)
amateurfunk-technische-kenntnisse-e.apkg (463 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
study pools from §3 axis 1: Technische = 1374 (cumulative N+E+A);
Betriebliche = 172 (shared across all candidates); Vorschriften = 204
(also shared). License class is conveyed via tags
(`klasse-N` / `klasse-E` / `klasse-A`), not separate packages —
Anki users browse decks by topic and filter by class.
Five `.apkg` files. Betriebliche and Vorschriften are shared across
every candidate (class-1-only in the data per §3 axis 2) and stay as
one deck each. Technische is fanned out per license class using a
**strict equality split** on the question's `class` field — class-1
questions land in the N deck only, class-2 in E only, class-3 in A
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
1. **Load** the latest catalog: follow `manifest-latest.json` to a
per-edition directory, read the catalog JSON, the per-edition
`manifest.json`, and index the `svgs/` folder.
2. **Categorize** the question tree into three `Category` objects,
one per top-level Prüfungsteil. 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).
2. **Categorize** the question tree into five `Category` objects.
Betriebliche and Vorschriften get one each. Technische is
additionally split into three sub-categories (one per license
class) via a strict equality match on the question's `class`
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:
- Compute a stable per-question seed from `--seed` + question
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 ß
to ASCII digraphs before NFKD-normalizing the rest, then keeps
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
`pfad-<slugified-section>` for every section level below the
top-level Prüfungsteil. 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.
top-level Prüfungsteil. The `klasse-*` tag is redundant within
the three Technische decks (every card already shares one class)
but is still emitted there for uniformity, and remains the
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
stored `Path` field both go through one `display_path()`
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
the embedded SQLite, and check structural invariants.
- Top-level structure: one `.apkg` per Prüfungsteil; expected
filename slugs.
- Top-level structure: five `.apkg` files (Betriebliche +
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.
- Tag mapping: a class-1 question carries `klasse-N`, no
`nummer-*` tags.
+6 -3
View File
@@ -13,9 +13,12 @@ make test # run both test suites
make clean # remove data/ and anki/
```
Output: three `.apkg` files under `anki/`, one per Prüfungsteil
(Technische / Betriebliche / Vorschriften). Import them into Anki —
re-importing a newer build preserves your review history.
Output: five `.apkg` files under `anki/`. Betriebliche and
Vorschriften get one deck each (shared across all license classes);
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
+59 -13
View File
@@ -7,10 +7,10 @@ script does:
1. Read the per-edition directory written by `amateurfunk_fetch.py`
(the JSON catalog, the `svgs/` folder, and the per-edition
`manifest.json`).
2. Split the catalog into three categories — one per top-level
Prüfungsteil: Technische / Betriebliche / Vorschriften. (The
license-class axis is mapped into tags, not into separate
packages.)
2. Split the catalog into five categories. Betriebliche and
Vorschriften get one each. Technische is additionally fanned
out per license class into three decks (N / E / A) via a
strict equality split on the question's `class` field.
3. For each category, render every question as an Anki note: front
shows the (shuffled) A/B/C/D choices, back names the displayed
position of the correct answer.
@@ -20,8 +20,8 @@ script does:
The script is intentionally a single file with stdlib only. Readability
beats cleverness here — most of the bytes below are docstrings and
comments. Performance is a non-goal (we build three small decks once
per upstream edition).
comments. Performance is a non-goal (we build a handful of small
decks once per upstream edition).
"""
import argparse
@@ -75,6 +75,16 @@ TOP_LEVEL_PREFIX = "Prüfungsfragen im Prüfungsteil: "
# search for.
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
# one. Picked as 0 so missing-metadata builds are still deterministic
# and obviously wrong (timestamps would all show 1970).
@@ -164,12 +174,18 @@ def load_latest_catalog(data_dir):
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
are exactly the three Prüfungsteile. We walk each subtree and
flatten every question it contains, recording the section path so
cards can show a breadcrumb and we can emit path-shaped tags.
Per DESIGN.md §3 axis 1 the catalog has three top-level sections
(the three Prüfungsteile). Technische Kenntnisse is additionally
fanned out per license class (N/E/A) — see DESIGN.md §7 "Output
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")
if not isinstance(sections, list):
@@ -181,6 +197,9 @@ def collect_categories(catalog):
questions = []
_collect_questions(section, (title,), questions)
short_title = _short_category_title(title)
if short_title == TECHNISCHE_SHORT_TITLE:
categories.extend(_split_by_class(title, short_title, questions))
else:
categories.append(
Category(
title=title,
@@ -192,6 +211,33 @@ def collect_categories(catalog):
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):
"""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.
Loads the latest fetched catalog, picks a build epoch, then walks
the three top-level categories writing one `.apkg` each. Raises
`AnkiBuildError` on configuration / catalog problems.
every category writing one `.apkg` each. Raises `AnkiBuildError`
on configuration / catalog problems.
"""
edition_dir, manifest, catalog = load_latest_catalog(data_dir)
build_epoch = build_epoch_from_manifest(manifest, override_epoch)
+39 -6
View File
@@ -32,6 +32,7 @@ def make_catalog():
"questions": [
question("NA101", "1", "Was ist A?", "NA101_q"),
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.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")
paths = sorted(path.name for path in self.out_dir.glob("*.apkg"))
self.assertEqual(
@@ -123,14 +124,46 @@ class TestAnkiBuild(unittest.TestCase):
[
"amateurfunk-betriebliche-kenntnisse.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):
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)
self.assertIn("collection.anki2", names)
@@ -150,8 +183,8 @@ class TestAnkiBuild(unittest.TestCase):
try:
note_count = conn.execute("select count(*) from notes").fetchone()[0]
card_count = conn.execute("select count(*) from cards").fetchone()[0]
self.assertEqual(note_count, 2)
self.assertEqual(card_count, 2)
self.assertEqual(note_count, 1)
self.assertEqual(card_count, 1)
fields = [row[0] for row in conn.execute("select flds from notes")]
finally:
conn.close()