Break A and E technical into subdecks

This commit is contained in:
2026-06-18 17:46:33 +02:00
parent 6a4db1d889
commit 8fc3c0c32e
5 changed files with 174 additions and 47 deletions
+12 -6
View File
@@ -44,8 +44,8 @@ BNetzA ZIP ──[Stage 1: amateurfunk_fetch.py]──► data/<slug>/
data/ ──[Stage 2: amateurfunk_anki.py]──► anki/ data/ ──[Stage 2: amateurfunk_anki.py]──► anki/
├── amateurfunk-technische-kenntnisse-n.apkg ├── amateurfunk-technische-kenntnisse-n.apkg
├── amateurfunk-technische-kenntnisse-e.apkg ├── amateurfunk-technische-kenntnisse-e.apkg (one file, 11 topic sub-decks)
├── amateurfunk-technische-kenntnisse-a.apkg ├── amateurfunk-technische-kenntnisse-a.apkg (one file, 11 topic sub-decks)
├── amateurfunk-betriebliche-kenntnisse.apkg ├── amateurfunk-betriebliche-kenntnisse.apkg
└── amateurfunk-kenntnisse-von-vorschriften.apkg └── amateurfunk-kenntnisse-von-vorschriften.apkg
@@ -76,10 +76,16 @@ technical.json ──[Stage 2c: amateurfunk_technical.py]──► anki/
`manifest-latest.json` to a per-edition directory). `manifest-latest.json` to a per-edition directory).
2. Split the catalog into five categories. Betriebliche and 2. Split the catalog into five categories. Betriebliche and
Vorschriften get one deck each (shared across every candidate). Vorschriften get one deck each (shared across every candidate).
Technische is additionally fanned out per license class into three Technische is additionally fanned out per license class using a
decks (N / E / A) using a strict equality split on the question's strict equality split on the question's `class` field: one `.apkg`
`class` field. The `klasse-N|E|A` tag is still emitted on every each for N, E, and A. The E (463) and A (716) packages — the large
note for inside-Anki filtering. pools — are each built as a deck *tree*: one sub-deck per
first-level catalog topic (the 11 subsections) under an anchoring
`Technische Kenntnisse::E` / `::A` parent, so each imports as a
single file but studies topic by topic. N stays a single flat deck.
The set of split classes is `TOPIC_SPLIT_CLASSES`. 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 `\(...\)`
+32 -13
View File
@@ -494,25 +494,44 @@ output artifacts.
``` ```
anki/ anki/
amateurfunk-technische-kenntnisse-n.apkg (195 cards) amateurfunk-technische-kenntnisse-n.apkg (195 cards)
amateurfunk-technische-kenntnisse-e.apkg (463 cards) amateurfunk-technische-kenntnisse-e.apkg (463 cards, 11 sub-decks)
amateurfunk-technische-kenntnisse-a.apkg (716 cards) amateurfunk-technische-kenntnisse-a.apkg (716 cards, 11 sub-decks)
amateurfunk-betriebliche-kenntnisse.apkg (172 cards) amateurfunk-betriebliche-kenntnisse.apkg (172 cards)
amateurfunk-kenntnisse-von-vorschriften.apkg (204 cards) amateurfunk-kenntnisse-von-vorschriften.apkg (204 cards)
``` ```
Five `.apkg` files. Betriebliche and Vorschriften are shared across Betriebliche and Vorschriften are shared across every candidate
every candidate (class-1-only in the data per §3 axis 2) and stay as (class-1-only in the data per §3 axis 2) and stay as one deck each.
one deck each. Technische is fanned out per license class using a Technische is fanned out per license class using a **strict equality
**strict equality split** on the question's `class` field — class-1 split** on the question's `class` field — class-1 questions land in
questions land in the N deck only, class-2 in E only, class-3 in A the N deck only, class-2 in E only, class-3 in A only. The card counts
only. The card counts therefore equal the new-at-this-class slices therefore equal the new-at-this-class slices from §3, not the
from §3, not the cumulative study pools: a candidate studying for cumulative study pools: a candidate studying for class E imports
class E imports Technische-N + Technische-E + Betriebliche + Technische-N + Technische-E + Betriebliche + Vorschriften.
Vorschriften.
The class-E (463) and class-A (716) Technische pools are large enough
that a single flat deck is unwieldy, so each package is built as a deck
*tree* rather than fanned out into separate files: one `.apkg`
containing one sub-deck per first-level catalog topic (the 11
subsections under the Prüfungsteil, e.g. "Sender und Empfänger",
"Antennen und Übertragungsleitungen") plus an anchoring `…::E` / `…::A`
parent deck. Each card is filed under its topic via the `did` column;
grouping is by the first section title below the Prüfungsteil
(`item.path[1]`). Anki sorts the sub-decks alphabetically on import (it
has no per-deck manual ordering), not in catalog order. N (195) is
small enough to stay one flat deck. The split classes are listed in
`TOPIC_SPLIT_CLASSES`. These multi-deck packages list every sub-deck in
the `col.decks` blob (see `build_apkg_for_category` and
`insert_collection_metadata`).
Note that re-importing a reworked package does not move cards that
already exist in a collection — Anki only files *new* cards by the
package's decks and leaves existing cards in their current deck.
The Technische deck names use Anki's `::` hierarchy separator The Technische deck names use Anki's `::` hierarchy separator
(`Amateurfunk::Technische Kenntnisse::N`) so the three decks render (`Amateurfunk::Technische Kenntnisse::N`, and one level deeper for E/A —
as children of a shared parent in Anki's deck browser. The `Amateurfunk::Technische Kenntnisse::A::Sender und Empfänger`) so the
decks render as a nested tree in Anki's deck browser. The
`klasse-N` / `klasse-E` / `klasse-A` tag is still emitted on every `klasse-N` / `klasse-E` / `klasse-A` tag is still emitted on every
note — redundant within each Technische deck but useful in Betr/Vor note — redundant within each Technische deck but useful in Betr/Vor
for in-Anki filtering, and harmless besides. for in-Anki filtering, and harmless besides.
+19 -9
View File
@@ -21,7 +21,12 @@ Output: seven `.apkg` files under `anki/`.
Vorschriften get one deck each (shared across all license classes); Vorschriften get one deck each (shared across all license classes);
Technische is split per class into three decks (N / E / A) following Technische is split per class into three decks (N / E / A) following
the catalog's `class` field. A class-A candidate who wants every the catalog's `class` field. A class-A candidate who wants every
Technische question imports all three Technische decks. Technische question imports all three Technische decks. The E (463)
and A (716) decks are large pools, so each ships as a deck *tree*
one sub-deck per first-level exam topic under `Technische Kenntnisse::E`
/ `::A` — so you can study them topic by topic instead of as one giant
deck. Each is still a single `.apkg`; N (195) stays flat (small enough
that sub-decks would add clutter without helping).
**Two glossary decks** of radio shorthand — Q-groups and operating **Two glossary decks** of radio shorthand — Q-groups and operating
abbreviations, and technical/HAM abbreviations — built from curated abbreviations, and technical/HAM abbreviations — built from curated
@@ -45,18 +50,23 @@ belongs to:
**Technical (one deck per license class):** **Technical (one deck per license class):**
| Class | Section name | ID prefix | Questions | | Class | Section name | ID prefix | Questions | Layout |
|-------|---------------------------|-----------|----------:| |-------|---------------------------|-----------|----------:|--------|
| N | Technische Kenntnisse (N) | `N*` | 195 | | N | Technische Kenntnisse (N) | `N*` | 195 | one flat deck |
| E | Technische Kenntnisse (E) | `E*` | 463 | | E | Technische Kenntnisse (E) | `E*` | 463 | 11 topic sub-decks |
| A | Technische Kenntnisse (A) | `A*` | 716 | | A | Technische Kenntnisse (A) | `A*` | 716 | 11 topic sub-decks |
Counts are from the current edition (3. Auflage, März 2024; ~1750 Counts are from the current edition (3. Auflage, März 2024; ~1750
questions total). The license tiers are cumulative for the exam: questions total). The class-E and class-A decks are each split into one
sub-deck per first-level exam topic (the 11 catalog subsections —
*Bauteile*, *Sender und Empfänger*, *Antennen …*, etc.) because those
pools are unwieldy as a single deck; each is still one `.apkg`
containing a deck tree. Anki sorts those sub-decks alphabetically on
import, not in exam order. The license tiers are cumulative for the exam:
a class-E candidate is responsible for `N*` + `E*` + `B*` + `V*`; a class-E candidate is responsible for `N*` + `E*` + `B*` + `V*`;
a class-A candidate is responsible for everything. Filter inside a class-A candidate is responsible for everything. Filter inside
Anki by deck, by the `klasse-N|E|A` tag, or by the `Number` field Anki by deck, by the `klasse-N|E|A` tag, or — for the topic within a
prefix. deck — by the `pfad-*` tag, or by the `Number` field prefix.
## Glossary decks ## Glossary decks
+69 -19
View File
@@ -103,6 +103,14 @@ CLASS_ORDER = [("1", "N"), ("2", "E"), ("3", "A")]
# shared across all candidates and stay as a single deck each. # shared across all candidates and stay as a single deck each.
TECHNISCHE_SHORT_TITLE = "Technische Kenntnisse" TECHNISCHE_SHORT_TITLE = "Technische Kenntnisse"
# License classes whose Technische package is built with one sub-deck
# per first-level catalog topic (the 11 subsections under the
# Prüfungsteil) instead of a single flat deck. The E (463) and A (716)
# pools are large enough that a single deck is unwieldy; N (195) stays
# a single flat deck. Still one `.apkg` per class — the sub-decks live
# inside the package as a deck tree.
TOPIC_SPLIT_CLASSES = frozenset({"E", "A"})
# 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).
@@ -145,13 +153,17 @@ class Category:
`title` is the original German title (with the Prüfungsteil `title` is the original German title (with the Prüfungsteil
prefix); `short_title` has the prefix stripped (used in the deck prefix); `short_title` has the prefix stripped (used in the deck
name and the slug); `questions` is the flat list of every question name and the slug); `questions` is the flat list of every question
that lives anywhere under this category. that lives anywhere under this category. When `subdeck_by_topic`
is set, the package is built with one sub-deck per first-level
catalog topic instead of a single flat deck (see
`build_apkg_for_category`).
""" """
title: str title: str
short_title: str short_title: str
slug: str slug: str
questions: list questions: list
subdeck_by_topic: bool = False
# ============================================================================ # ============================================================================
@@ -355,7 +367,10 @@ def _split_by_class(title, short_title, questions):
The short title uses Anki's `::` deck-hierarchy separator so the The short title uses Anki's `::` deck-hierarchy separator so the
three decks render as children of a shared parent in Anki's deck three decks render as children of a shared parent in Anki's deck
browser (e.g. `Amateurfunk::Technische Kenntnisse::N`). browser (e.g. `Amateurfunk::Technische Kenntnisse::N`). The classes
in `TOPIC_SPLIT_CLASSES` (E, A) stay a single package each but are
built with one sub-deck per first-level topic — see
`build_apkg_for_category` and the `subdeck_by_topic` flag.
""" """
base_slug = slugify(short_title) base_slug = slugify(short_title)
for digit, letter in CLASS_ORDER: for digit, letter in CLASS_ORDER:
@@ -368,6 +383,7 @@ def _split_by_class(title, short_title, questions):
short_title=f"{short_title}::{letter}", short_title=f"{short_title}::{letter}",
slug=f"{base_slug}-{letter.lower()}", slug=f"{base_slug}-{letter.lower()}",
questions=subset, questions=subset,
subdeck_by_topic=(letter in TOPIC_SPLIT_CLASSES),
) )
@@ -843,8 +859,12 @@ def build_apkg_for_category(
media = MediaRegistry(edition_dir / "svgs") media = MediaRegistry(edition_dir / "svgs")
deck_name = f"Amateurfunk::{category.short_title}" # The category's own deck is always present (even empty, it anchors
deck_id = stable_id("deck", deck_name) # the tree). Sub-decks discovered per note are added on first use,
# in catalog order, so the package's deck list is deterministic.
root_deck_name = f"Amateurfunk::{category.short_title}"
root_deck_id = stable_id("deck", root_deck_name)
decks = {root_deck_name: root_deck_id}
model_id = stable_id("model", "Amateurfunk Multiple Choice") model_id = stable_id("model", "Amateurfunk Multiple Choice")
try: try:
@@ -858,11 +878,14 @@ def build_apkg_for_category(
number = str(question.get("number", f"q{ordinal}")) number = str(question.get("number", f"q{ordinal}"))
if number in explanations: if number in explanations:
applied_explanations += 1 applied_explanations += 1
deck_short = _deck_short_title_for_item(category, item)
deck_name = f"Amateurfunk::{deck_short}"
deck_id = decks.setdefault(deck_name, stable_id("deck", deck_name))
note_id = stable_id("note", f"{category.slug}:{number}") note_id = stable_id("note", f"{category.slug}:{number}")
card_id = stable_id("card", f"{category.slug}:{number}") card_id = stable_id("card", f"{category.slug}:{number}")
fields = [ fields = [
number, number,
category.short_title, deck_short,
display_path(item.path), display_path(item.path),
front, front,
back, back,
@@ -871,6 +894,7 @@ def build_apkg_for_category(
notes.append({ notes.append({
"note_id": note_id, "note_id": note_id,
"card_id": card_id, "card_id": card_id,
"deck_id": deck_id,
"guid": stable_guid(f"{category.slug}:{number}"), "guid": stable_guid(f"{category.slug}:{number}"),
"fields": FIELD_SEP.join(fields), "fields": FIELD_SEP.join(fields),
"sort": number, "sort": number,
@@ -882,8 +906,8 @@ def build_apkg_for_category(
create_collection_db( create_collection_db(
db_path, db_path,
deck_id=deck_id, decks=decks,
deck_name=deck_name, cur_deck_id=root_deck_id,
model_id=model_id, model_id=model_id,
notes=notes, notes=notes,
now=build_epoch, now=build_epoch,
@@ -896,7 +920,8 @@ def build_apkg_for_category(
return { return {
"path": out_path, "path": out_path,
"deck": deck_name, "deck": root_deck_name,
"subdecks": len(decks) - 1,
"questions": len(category.questions), "questions": len(category.questions),
"media": len(media_paths), "media": len(media_paths),
"missing_media": sorted(set(media.missing)), "missing_media": sorted(set(media.missing)),
@@ -904,6 +929,21 @@ def build_apkg_for_category(
} }
def _deck_short_title_for_item(category, item):
"""Return the `::`-joined deck short title a card lands in.
Normally the category's own short title. When the category has
`subdeck_by_topic` set, the first-level catalog topic (the section
title just below the Prüfungsteil, `item.path[1]`) is appended so
the card lands in a sub-deck such as
`Technische Kenntnisse::A::Sender und Empfänger`. Items with no
topic level fall back to the category deck so nothing is dropped.
"""
if category.subdeck_by_topic and len(item.path) > 1:
return f"{category.short_title}::{item.path[1]}"
return category.short_title
def write_apkg(out_path, db_path, media_paths, build_epoch): def write_apkg(out_path, db_path, media_paths, build_epoch):
"""Write the Anki package ZIP atomically. """Write the Anki package ZIP atomically.
@@ -1013,21 +1053,27 @@ def svg_with_white_background(svg_text):
# ============================================================================ # ============================================================================
def create_collection_db(db_path, deck_id, deck_name, model_id, notes, now): def create_collection_db(db_path, decks, cur_deck_id, model_id, notes, now):
"""Create the `collection.anki2` SQLite database for one package. """Create the `collection.anki2` SQLite database for one package.
Uses the v11 schema, which modern Anki still understands (it Uses the v11 schema, which modern Anki still understands (it
upgrades the collection on first open). We pre-write a single upgrades the collection on first open). We pre-write a single
`col` row with the JSON config blobs, then insert one `notes` `col` row with the JSON config blobs, then insert one `notes`
row and one `cards` row per question. row and one `cards` row per question.
`decks` maps every deck name in this package to its id; a package
is usually a single deck, but the per-topic Technische package
carries one entry per sub-deck plus the anchoring parent.
`cur_deck_id` is the deck selected on open. Each note carries its
own `deck_id`, so cards land in the right sub-deck.
""" """
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
try: try:
create_schema(conn) create_schema(conn)
insert_collection_metadata( insert_collection_metadata(
conn, conn,
deck_id=deck_id, decks=decks,
deck_name=deck_name, cur_deck_id=cur_deck_id,
model_id=model_id, model_id=model_id,
now=now, now=now,
) )
@@ -1062,7 +1108,7 @@ def create_collection_db(db_path, deck_id, deck_name, model_id, notes, now):
( (
note["card_id"], note["card_id"],
note["note_id"], note["note_id"],
deck_id, note["deck_id"],
0, 0,
now, now,
-1, -1,
@@ -1163,12 +1209,19 @@ def create_schema(conn):
) )
def insert_collection_metadata(conn, deck_id, deck_name, model_id, now): def insert_collection_metadata(conn, decks, cur_deck_id, model_id, now):
"""Write the single `col` row that carries the JSON config blobs. """Write the single `col` row that carries the JSON config blobs.
`crt` is a seconds-epoch creation time; `mod` and `scm` are in `crt` is a seconds-epoch creation time; `mod` and `scm` are in
milliseconds (Anki's mixed convention, not ours to fix). milliseconds (Anki's mixed convention, not ours to fix). `decks`
maps deck name → id; every entry is written to `col.decks` so a
package can ship a deck tree, not just one deck. `cur_deck_id` is
the deck made current in `col.conf`.
""" """
decks_json = {
str(deck_id): deck_json(deck_id, deck_name, now)
for deck_name, deck_id in decks.items()
}
conn.execute( conn.execute(
""" """
INSERT INTO col INSERT INTO col
@@ -1184,15 +1237,12 @@ def insert_collection_metadata(conn, deck_id, deck_name, model_id, now):
0, 0,
0, 0,
0, 0,
json.dumps(collection_conf(deck_id), separators=(",", ":")), json.dumps(collection_conf(cur_deck_id), separators=(",", ":")),
json.dumps( json.dumps(
{str(model_id): model_json(model_id, now)}, {str(model_id): model_json(model_id, now)},
separators=(",", ":"), separators=(",", ":"),
), ),
json.dumps( json.dumps(decks_json, separators=(",", ":")),
{str(deck_id): deck_json(deck_id, deck_name, now)},
separators=(",", ":"),
),
json.dumps(default_deck_conf(now), separators=(",", ":")), json.dumps(default_deck_conf(now), separators=(",", ":")),
"{}", "{}",
), ),
+42
View File
@@ -193,6 +193,48 @@ class TestAnkiBuild(unittest.TestCase):
{"N": ["NA101"], "E": ["EA202"], "A": ["AA303"]}, {"N": ["NA101"], "E": ["EA202"], "A": ["AA303"]},
) )
def test_split_class_packages_ship_topic_subdeck_tree(self):
# The E and A packages each stay one file but carry a deck tree:
# the anchoring class deck plus one sub-deck per first-level
# topic, with each card filed under its topic sub-deck.
self._build_all()
for letter in ("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:
decks = json.loads(
conn.execute("select decks from col").fetchone()[0]
)
deck_names = {d["name"] for d in decks.values()}
id_to_name = {int(k): d["name"] for k, d in decks.items()}
card_decks = sorted(
id_to_name[row[0]]
for row in conn.execute("select did from cards")
)
finally:
conn.close()
root = f"Amateurfunk::Technische Kenntnisse::{letter.upper()}"
self.assertEqual(deck_names, {root, f"{root}::Grundlagen"})
self.assertEqual(card_decks, [f"{root}::Grundlagen"])
def test_single_topic_classes_ship_one_flat_deck(self):
# N (and E) are not split: one deck, card filed directly in it.
self._build_all()
apkg = self.out_dir / "amateurfunk-technische-kenntnisse-n.apkg"
db_path, _media, _names = extract_collection(apkg, self.root / "n")
conn = sqlite3.connect(db_path)
try:
decks = json.loads(
conn.execute("select decks from col").fetchone()[0]
)
finally:
conn.close()
self.assertEqual(
{d["name"] for d in decks.values()},
{"Amateurfunk::Technische Kenntnisse::N"},
)
def test_apkg_contains_notes_cards_and_media(self): def test_apkg_contains_notes_cards_and_media(self):
self._build_all() self._build_all()
apkg = self.out_dir / "amateurfunk-technische-kenntnisse-n.apkg" apkg = self.out_dir / "amateurfunk-technische-kenntnisse-n.apkg"