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
+66 -20
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,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)