From 0d635a8587a2d5a8dce1165fcd78d6a714bf68a3 Mon Sep 17 00:00:00 2001 From: Renat Nurgaliyev Date: Fri, 22 May 2026 15:05:46 +0200 Subject: [PATCH] Split technical questions into per-class decks --- CLAUDE.md | 16 +++++--- DESIGN.md | 66 ++++++++++++++++++++---------- README.md | 9 +++-- amateurfunk_anki.py | 86 ++++++++++++++++++++++++++++++---------- test_amateurfunk_anki.py | 45 ++++++++++++++++++--- 5 files changed, 167 insertions(+), 55 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index df05e1e..672bcb4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,9 @@ BNetzA ZIP ──[Stage 1: amateurfunk_fetch.py]──► data// └── 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 diff --git a/DESIGN.md b/DESIGN.md index d7712b3..a37976f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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-` 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. diff --git a/README.md b/README.md index dbab8f7..fa91dd2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/amateurfunk_anki.py b/amateurfunk_anki.py index 8698abf..7af5541 100644 --- a/amateurfunk_anki.py +++ b/amateurfunk_anki.py @@ -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,17 +197,47 @@ def collect_categories(catalog): questions = [] _collect_questions(section, (title,), questions) short_title = _short_category_title(title) - categories.append( - Category( - title=title, - short_title=short_title, - slug=slugify(short_title), - questions=questions, + if short_title == TECHNISCHE_SHORT_TITLE: + categories.extend(_split_by_class(title, short_title, questions)) + else: + categories.append( + Category( + title=title, + short_title=short_title, + slug=slugify(short_title), + questions=questions, + ) ) - ) 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) diff --git a/test_amateurfunk_anki.py b/test_amateurfunk_anki.py index 7dc33f2..804f350 100644 --- a/test_amateurfunk_anki.py +++ b/test_amateurfunk_anki.py @@ -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()