Split technical questions into per-class decks
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+66
-20
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user