Compare commits

...

2 Commits

Author SHA1 Message Date
imple 27988780cf Add explanations 2026-05-22 16:33:47 +02:00
imple 0d635a8587 Split technical questions into per-class decks 2026-05-22 15:05:46 +02:00
7 changed files with 1097 additions and 70 deletions
+20 -6
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,14 +68,20 @@ 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 `\(...\)`
delimiters; the catalog's safe inline markup (`<u>...</u>`) is
preserved.
preserved. If the question's number has an entry in
`explanations.json` (see EXPLANATIONS.md), an English explanation
block is appended to the back; a "low confidence" badge shows for
entries with `confidence < 7`.
4. Hand-roll the v11 Anki collection (SQLite + JSON config) and
package it as a `.apkg` ZIP with deterministic timestamps. Same
input → byte-identical output across runs.
@@ -98,10 +106,16 @@ SVG dark-mode handling, schema choices) live in `DESIGN.md` §7.
## Working on this repo
- `EXPLANATIONS.md` — the editorial contract for agents asked to add
or improve per-question explanations. The schema, the workflows
("explain everything unexplained", "improve everything below
confidence 7"), and the source/confidence guidance live there.
`explanations.json` is an empty `{}` until agents populate it.
- 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
+68 -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
@@ -611,6 +632,22 @@ byte-identical sha256 on each `.apkg`. Verified during review.
the card background. We inject a white `<rect>` as the first
painted element when packaging the SVG into the `.apkg`. The
on-disk extracted files are left untouched.
- **Explanations layer (optional).** `amateurfunk_anki.py` loads
`explanations.json` (CLI: `--explanations`) and, for any
question number found there, appends an English explanation
block to the back of the card. The block is styled distinctly
(serif italic body, sans-serif metadata, top border) and shows
a small "low confidence" badge when the entry's `confidence`
field is below `LOW_CONFIDENCE_THRESHOLD` (= 7). `revision` and
the raw `confidence` number are editorial-only and never
displayed. A missing file is treated as an empty database; a
malformed entry is a hard `AnkiBuildError`. The editorial
contract (schema, sourcing, agent workflows) lives in
`EXPLANATIONS.md`; this file documents only the wiring on the
build side. Note-GUID stability across explanation edits: the
GUID is keyed on `category.slug:number`, not on field content,
so adding/editing an entry updates the existing Anki note on
re-import rather than producing a duplicate.
### Testing focus
@@ -618,8 +655,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.
@@ -643,6 +683,13 @@ the embedded SQLite, and check structural invariants.
- Deterministic build: two consecutive builds against the same
catalog with a fixed `--epoch` produce byte-identical `.apkg`
files.
- Explanations layer: a present entry produces a styled
explanation block on the back card; a missing entry leaves the
card unchanged; `confidence < 7` surfaces the "low confidence"
badge; URL sources render as `<a>`, citation sources render as
escaped text; a missing `explanations.json` is silently empty;
a malformed entry (bad type, out-of-range confidence, missing
field) raises `AnkiBuildError`.
### What we deliberately do NOT do in Stage 2
+425
View File
@@ -0,0 +1,425 @@
# Explanations — agent-authored answer rationales
This document is the working contract for any AI agent (or human)
asked to **add or improve explanations** for questions in the BNetzA
amateur-radio catalog. The decks built by `amateurfunk_anki.py`
optionally append an English explanation block to the back of each
card; the text of those explanations lives in `explanations.json` at
the repo root and is edited by hand (or by an agent following this
file).
The build is non-blocking on this data: a missing file or missing
entry just produces a card without an explanation block. Adding an
entry is purely additive — no regenerate ceremony beyond
`make anki`.
---
## 1. File location and shape
- **Path:** `explanations.json` at the repo root. Tracked in git.
- **Encoding:** UTF-8, no BOM, two-space indent for readability.
- **Top level:** a JSON object keyed by **question number** exactly
as it appears in the catalog (e.g. `"NA101"`, `"BA205"`,
`"EH410"`). Keys are case-sensitive.
- **Sort order:** keep keys in alphabetical order. Diffs stay clean
and merge conflicts get easier; the build does not care.
### Per-entry schema
Every entry MUST have exactly these four fields:
| Field | Type | Constraint |
|---------------|---------|--------------------------------------------|
| `revision` | integer | `>= 1`. Starts at `1`, bumps on improvement |
| `explanation` | string | Non-empty. **English.** Terse. WHY-focused |
| `source` | string | Non-empty. URL or citation like `AFuV §16(2)` |
| `confidence` | integer | `1..10` inclusive. See scale in §5 |
Extra keys are rejected by `load_explanations()` — the build fails
with `unknown fields [...]` listing them. The loader is similarly
strict about types: a JSON `true` will not satisfy the integer
contract for `revision` or `confidence`. If you need to track
editorial metadata that isn't shown on the card, propose a schema
change rather than smuggling fields in.
Top-level keys (the question numbers) must also match the catalog
exactly. An entry keyed on a number that no live question carries
(typo, stale ID after a catalog revision) fails the build with
`explanation keys not present in the catalog: ...`. Fix the key, or
remove the entry.
### Minimal example
```json
{
"NA101": {
"revision": 1,
"explanation": "100 m weighs 210 g, so 55 g is 55/210 of 100 m ≈ 26.2 m. Mass scales linearly with length for the same wire.",
"source": "https://50ohm.de/lernen/wissen/elektrotechnik-mathematik",
"confidence": 8
}
}
```
The build appends this on the back card as a styled block headed
**Explanation**, with the source rendered as a clickable link when
it looks like an HTTP(S) URL.
---
## 2. How the explanation reaches the card
`amateurfunk_anki.py` does the wiring; the agent does not need to
modify Python code to ship a new explanation. End-to-end:
1. `load_explanations(./explanations.json)` parses the file and
validates each entry against the schema above. A malformed entry
is a hard build error.
2. While rendering a card, `render_question()` looks up the
question's `number` in the dict. On a hit, `render_explanation()`
produces an HTML block: header "Explanation", the body (with
inline `$...$` LaTeX rewritten to MathJax `\(...\)`, same as the
question text), and a "Source: ..." line.
3. The block lands inside `.af-back` at the very end, styled by the
`.af-explanation*` CSS rules — serif italic body, sans-serif
metadata, separated from the answer by a top border. When the
entry's `confidence` is **below 7**, a small "low confidence"
badge appears next to the **Explanation** header — a hint to the
learner that the reasoning may be incomplete or weakly sourced.
`revision` is never shown on the card.
4. The note GUID is keyed on `category.slug:number`, NOT on field
content, so adding/changing an explanation does **not** create
duplicate cards on re-import — Anki updates the existing note in
place.
Run `make anki` (or `python3 amateurfunk_anki.py`) after editing the
file. The CLI summary line will show `... N with explanations` per
deck.
---
## 3. Locating a question to explain
Questions live in the JSON catalog under the per-edition data
directory:
```
data/<slug>/fragenkatalog<edition>.json
```
To find one by number, e.g. `EH410`:
```sh
python3 -c '
import json, glob
for path in glob.glob("data/*/fragenkatalog*.json"):
cat = json.load(open(path))
def walk(node, prefix=()):
for q in node.get("questions", []) or []:
if q["number"] == "EH410":
print(json.dumps(q, ensure_ascii=False, indent=2))
print(" / ".join(prefix))
for s in node.get("sections", []) or []:
walk(s, prefix + (s.get("title", "?"),))
walk(cat)
'
```
You get the question stem, the four answers (`answer_a` is the
**correct** one upstream; the deck shuffles before display), the
class digit (`"1"` = N, `"2"` = E, `"3"` = A), and the section path.
That path tells you the topic context — useful when choosing a
source citation.
---
## 4. Writing the explanation
### Style
- **Language: English.** The questions and answers stay in German
on the card; the explanation is the one English element. Don't
switch.
- **Terse.** Aim for 13 sentences. The card already shows the
question, the correct answer text, and the breadcrumb — the
explanation should add the missing *why*, not restate any of
those.
- **Lead with the reasoning.** Don't open with "The correct answer
is X because..." — the card already declares the correct answer
one line above. Go straight to the principle, the formula, the
rule of thumb.
- **Math.** Use inline `$...$`; it's rewritten to MathJax on the
card, same as for question text. Don't use display math `$$...$$`
(the deck has no MathJax support for those).
- **Inline emphasis.** `<u>...</u>` is the only HTML you should
type by hand. Everything else gets HTML-escaped.
- **No spoilers about other answers.** If a distractor is a common
trap, naming the trap is fine; quoting the distractor's text is
noise.
### What "WHY-focused" means in practice
| Less useful | More useful |
|----------------------------------------------|-----------------------------------------------|
| "The correct answer is 26.2 m." | "55 g is 55/210 of 100 m by mass scaling." |
| "Because regulations require it." | "AFuV §16(2): only class A may operate ≤10 m." |
| "It's how the formula works." | "Q is reactance over resistance, so Q rises as R falls." |
If the *only* honest explanation is "memorize the table" (e.g. a
band-plan lookup), say that plainly and cite the band plan in
`source` — confidence stays low (34) until someone finds a deeper
hook.
---
## 5. Confidence scale
| Score | Meaning |
|------:|------------------------------------------------------------------------|
| 10 | Direct quote / arithmetic restatement from a primary legal source. |
| 89 | Derivation from a well-known formula or law text; no ambiguity. |
| 67 | Reasoning sound but condensed; a reviewer might want one more line. |
| 45 | Educated guess based on context; corroborating source is weak. |
| 13 | Best-effort placeholder. Flag for re-explanation. |
When in doubt, score lower. The query "explain everything below
confidence 7" is a real workflow — a too-generous score hides work
that should be redone.
The score is also capped by the source tier — see §6 "Source ↔
confidence linkage". A tier-8 (general web) source caps confidence
at 5 regardless of how persuasive the prose is.
**Card surface.** Confidence below 7 also surfaces a "low
confidence" badge on the card itself (see §2). This is deliberate:
the learner sees that the explanation is provisional rather than
trusting it as definitive. Scoring 7 turns the badge off, so do
not nudge a 6 to a 7 just to clear the badge — fix the explanation
or the source first.
---
## 6. Sources
Use the **strictest tier that actually answers the question**. Drop
down a tier only when the higher one doesn't cover the topic — never
because a lower-tier source is easier to find. The exam tests
knowledge of German law and BNetzA-curated material; a primary-law
question backed by a random tutorial site is a worse explanation
than no explanation at all.
The full priority order, top (most preferred) to bottom:
1. **Primary German law.**
- AFuG (Amateurfunkgesetz), AFuV (Amateurfunkverordnung), BEMFV,
TKG when applicable, plus frequency-allocation ordinances
(vfg / VVnömL).
- Canonical URL pattern: `https://www.gesetze-im-internet.de/<gesetz>/__<§>.html`.
Cite as `AFuV §16(2)` style. Use the URL form when possible so
the card link is clickable.
- If a question's answer is fixed by German law, **no lower tier
is acceptable** as the sole source.
2. **EU / international regulation binding on Germany.**
- CEPT recommendations (T/R 61-01, T/R 61-02), ITU Radio
Regulations, ECC decisions, EU EMC / RED directives.
- Use `docdb.cept.org/...` or the ITU document portal as the
canonical URL.
- Reach for this tier when the question references CEPT
licensing, foreign operation, or harmonised band plans that
AFuV doesn't restate.
3. **BNetzA official publications.**
- The question catalog's own `README.txt`, BNetzA "Vfg"
announcements, the German band plan as published by BNetzA,
official explanatory notes.
- Useful when primary law is too terse to convey the *why* on
its own — these are the regulator's own gloss on the law.
4. **German amateur-radio organisations.**
- DARC publications and band plan (`darc.de`), Runder Tisch
Amateurfunk (RTA) papers, **50ohm.de** (the community study
site linked from `README.md`).
- Exam-aligned by community convention; well-suited to the WHY
when law text alone isn't pedagogically clear.
5. **International amateur-radio organisations.**
- IARU Region 1 documents (Germany is in R1) and DARC's IARU R1
liaison material; ARRL publications when they explain the
underlying physics or an internationally harmonised band plan.
- ARRL is **not** a source for German licensing rules — don't
use it that way.
6. **Standards bodies and primary technical references.**
- NIST, IEEE, IEC, ITU-R recommendations, ETSI standards (when
not already binding via tier 2).
- Appropriate for purely technical questions — units, defining
equations, definitions — where no amateur-radio-specific
source applies.
7. **Established engineering references.**
- University lecture notes hosted by the university, recognised
EE textbook publishers, well-known authors' personal sites.
- Use only when tiers 16 don't cover the topic.
8. **General web technical sites — last resort.**
- Wikipedia, tutorial sites (`allaboutcircuits.com`,
`amateur-radio-wiki.net`, `biopac.com`, etc.), random
manufacturer pages, blog posts.
- **Treat citations at this tier as temporary.** Score
`confidence` ≤ 5 and consider the entry a §8.3 candidate for
re-sourcing the moment a higher-tier source is identified.
Never use a tier-8 source as the sole citation for a question
that has a clear answer in tiers 14.
### Source ↔ confidence linkage
The §5 confidence scale isn't independent of the tier:
- Tier 12 + sound derivation → 910 is appropriate.
- Tier 34 → cap at 8 unless the source quotes a primary citation.
- Tier 56 → cap at 7.
- Tier 7 → cap at 6.
- Tier 8 (sole source) → cap at 5. Flag for re-sourcing.
This isn't a hard limit the validator enforces — it's editorial
discipline. If you find yourself wanting confidence 9 with a
tier-7 source, you almost always either (a) need to find the
tier-14 source that backs the same claim, or (b) are about to
overstate certainty.
### Form
If the source is a URL, store it as one — `_source_html()` makes
plain `http://` / `https://` strings clickable. Mixed text+URL
sources are stored verbatim and rendered as escaped text (no auto
link), so prefer one or the other. A short citation like
`AFuV §16(2), gesetze-im-internet.de/afuv_2005/__16.html` is
acceptable but loses click-through. When possible, narrow the
source to a single canonical URL.
---
## 7. Revisions
`revision` is editorial bookkeeping; it never appears on the card.
- A brand-new entry starts at `revision: 1`.
- **Bump** when you materially improve the explanation, broaden the
source, or fix an error. A typo fix is not a revision bump.
- Don't lower the revision. If a previous revision was wrong, fix
it forward and explain the fix in the commit message.
- A revision bump usually pairs with a confidence bump (or, more
rarely, with a deliberate confidence reset to indicate the
reviewer is less sure than the prior author was).
---
## 8. Workflows
These are the common ways the file gets edited. The agent should
read the request and pick one.
### 8.1 "Explain question X"
1. Look up X in the catalog (§3) — read the stem, the correct
answer (`answer_a` upstream), and the section path.
2. Identify *why* the correct answer is correct. Reach for a
primary source before paraphrasing 50ohm.de.
3. Draft the body per §4, pick a source per §6, score honestly
per §5.
4. Open `explanations.json`, insert the new entry in **alphabetical
key order**, save.
5. Run `python3 -m unittest test_amateurfunk_anki` — the schema
validator runs as part of the build path used by tests, so a
typo'd entry fails fast.
6. Rebuild decks (`make anki`) and spot-check the new entry on
the back of the card.
### 8.2 "Explain every question that doesn't have an entry yet"
This is bulk work — handle it in chunks, not as one giant batch.
1. Compute the unexplained set:
```sh
python3 -c '
import json, glob
catalog = next(iter(glob.glob("data/*/fragenkatalog*.json")))
exp = json.load(open("explanations.json"))
def walk(n):
for q in n.get("questions", []) or []: yield q["number"]
for s in n.get("sections", []) or []: yield from walk(s)
missing = sorted(set(walk(json.load(open(catalog)))) - exp.keys())
print(len(missing), "questions unexplained")
print("\n".join(missing[:20]))
'
```
2. Process in groups of ~10 within one topic. Same section path
often shares one source citation, so context stays warm and
confidence scores stay consistent.
3. After each group, save and run the tests. Don't accumulate
hundreds of unsaved entries.
### 8.3 "Improve all entries with confidence below N"
1. List low-confidence entries:
```sh
python3 -c '
import json
exp = json.load(open("explanations.json"))
for k, v in sorted(exp.items()):
if v["confidence"] < 7: print(k, v["confidence"], "—", v["source"])
'
```
2. For each, look up the question (§3), seek a stronger source
(§6), rewrite if the new source changes the framing.
3. **Bump `revision`** and update `confidence` to reflect the new
evidence. Do not just bump confidence without changing anything
else — that's how bad explanations entrench themselves.
### 8.4 "This explanation is wrong"
1. Read the current entry. Confirm the error against the catalog
stem and the cited source.
2. Rewrite the body, replace the source if needed, bump
`revision`, and re-score `confidence` from scratch (don't carry
over the prior score).
3. Commit message should name the question number and summarize
what was wrong — future agents need to know what kind of
mistake to look for elsewhere.
---
## 9. Validation checklist
Before saving, confirm:
- [ ] Key is the exact catalog `number` (case-sensitive).
- [ ] All four fields present, correct types, in range.
- [ ] `explanation` is English, terse, WHY-focused.
- [ ] `source` is non-empty and as primary as possible.
- [ ] `revision` is correct (1 for new, bumped for improvement).
- [ ] `confidence` honestly reflects how well the source supports
the explanation.
- [ ] Keys remain in alphabetical order in the file.
- [ ] `python3 -m unittest test_amateurfunk_anki` passes.
---
## 10. What's out of scope here
- **Translating questions into English.** The cards are bilingual
by design: German question + English explanation. Don't
paraphrase the German.
- **Editing the catalog itself.** `data/` is a build artifact —
refreshed by `amateurfunk_fetch.py` from BNetzA. Mistakes in the
upstream questions are upstream's to fix.
- **Removing explanations.** If an entry is genuinely useless, fix
it forward (§8.4). Don't delete keys — that loses the audit
trail. (The schema doesn't currently encode "retracted", so if
you ever need it, propose a schema change.)
+22 -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
@@ -47,11 +50,27 @@ question catalog the decks built here are based on.
Python 3.11+, standard library only. No third-party dependencies.
## Explanations
The back of each card optionally carries a terse English explanation
of *why* the right answer is right. Explanations are not part of the
BNetzA catalog — they're authored separately (by humans or AI agents)
into `explanations.json` at the repo root. The build is non-blocking
on this: questions without an entry just show no explanation block.
Entries with `confidence < 7` render a small "low confidence" badge
so learners know the reasoning is provisional.
`EXPLANATIONS.md` is the editorial contract: schema, sourcing
guidance, confidence scale, and the workflows an AI agent should
follow when asked to add or improve entries.
## More
- `CLAUDE.md` — project orientation, pipeline overview.
- `DESIGN.md` — source-discovery notes, JSON schema, per-stage
design contracts.
- `EXPLANATIONS.md` — schema + workflows for the explanations
database.
## License
+329 -29
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
@@ -52,6 +52,23 @@ DEFAULT_DATA_DIR = Path("data")
# Default destination for the generated `.apkg` files.
DEFAULT_OUT_DIR = Path("anki")
# Default location of the per-question explanations database. The
# file is editorial content (tracked in git), not a build artifact.
# A missing file is treated as "no explanations available" — the
# decks still build cleanly.
DEFAULT_EXPLANATIONS_PATH = Path("explanations.json")
# Required fields on every entry in the explanations database. See
# EXPLANATIONS.md for the full editorial contract.
EXPLANATION_FIELDS = ("revision", "explanation", "source", "confidence")
# Confidence values strictly below this threshold are surfaced on
# the card as a "low confidence" badge — a learner-facing warning
# that the explanation may be incomplete or weakly sourced. Matches
# the editorial cutoff used in EXPLANATIONS.md §5/§8.3 ("everything
# below 7 is review-worthy").
LOW_CONFIDENCE_THRESHOLD = 7
# Exit codes. The builder is much simpler than the fetcher — there is
# no "operator must resolve local state" case here, so two codes are
# enough.
@@ -75,6 +92,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).
@@ -158,18 +185,139 @@ def load_latest_catalog(data_dir):
return edition_dir, manifest, catalog
# ============================================================================
# Explanations database
# ============================================================================
def load_explanations(path):
"""Return the per-question explanations dict from a JSON file.
A missing file is treated as an empty database — the build still
runs and simply doesn't append an explanation block to any card.
A malformed file (bad JSON, wrong shape, missing fields, out of
range confidence/revision) is a hard error so editorial mistakes
don't silently ship.
Expected on-disk shape (full schema lives in `EXPLANATIONS.md`):
{
"NA101": {
"revision": 1,
"explanation": "Terse English text explaining why ...",
"source": "https://... or AFuV §16(2)",
"confidence": 7
},
...
}
"""
if path is None or not path.exists():
return {}
try:
raw = json.loads(path.read_text("utf-8"))
except (OSError, json.JSONDecodeError) as e:
raise AnkiBuildError(
f"could not read explanations file {path}: {e}"
) from e
if not isinstance(raw, dict):
raise AnkiBuildError(
f"explanations file {path} must contain a JSON object at the top level"
)
cleaned = {}
for key, value in raw.items():
_validate_explanation(key, value)
cleaned[str(key)] = value
return cleaned
def _validate_explanation(key, value):
"""Raise `AnkiBuildError` if one explanation entry is malformed.
Strict shape check: the four documented fields are required, no
extras allowed, and integer fields reject `bool` (which would
pass `isinstance(..., int)` because `bool` subclasses `int` in
Python). Extras are rejected so a stray `"note"` or `"author"`
field can't accumulate silently and drift from the documented
schema in EXPLANATIONS.md.
"""
if not isinstance(value, dict):
raise AnkiBuildError(
f"explanation {key!r} must be a JSON object"
)
missing = [f for f in EXPLANATION_FIELDS if f not in value]
if missing:
raise AnkiBuildError(
f"explanation {key!r} missing required fields: {missing}"
)
extra = sorted(set(value) - set(EXPLANATION_FIELDS))
if extra:
raise AnkiBuildError(
f"explanation {key!r}: unknown fields {extra}"
)
if type(value["revision"]) is not int or value["revision"] < 1:
raise AnkiBuildError(
f"explanation {key!r}: revision must be a positive integer"
)
if (
type(value["confidence"]) is not int
or not 1 <= value["confidence"] <= 10
):
raise AnkiBuildError(
f"explanation {key!r}: confidence must be an integer in 1..10"
)
if not isinstance(value["explanation"], str) or not value["explanation"].strip():
raise AnkiBuildError(
f"explanation {key!r}: explanation must be a non-empty string"
)
if not isinstance(value["source"], str) or not value["source"].strip():
raise AnkiBuildError(
f"explanation {key!r}: source must be a non-empty string"
)
def _check_explanation_keys_against_catalog(explanations, categories):
"""Raise `AnkiBuildError` if any explanation key has no matching question.
EXPLANATIONS.md makes the catalog `number` part of the editorial
contract. A typo like `"NA10I"` for `"NA101"` would otherwise
pass schema validation and silently never appear on any card.
We fail the build instead, listing the unknown keys so the
editor can fix them.
"""
if not explanations:
return
known = set()
for category in categories:
for item in category.questions:
number = item.question.get("number")
if number is not None:
known.add(str(number))
unknown = sorted(set(explanations) - known)
if unknown:
raise AnkiBuildError(
"explanation keys not present in the catalog: "
+ ", ".join(unknown)
)
# ============================================================================
# Category collection
# ============================================================================
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 +329,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`.
@@ -337,18 +515,22 @@ def randomized_answers(question, seed):
# ============================================================================
def render_question(item, media, seed):
def render_question(item, media, seed, explanations=None):
"""Render one question as `(front_html, back_html, correct_label)`.
The front shows the question stem, an optional figure, and the
four shuffled answer choices as an ordered list with `type="A"`.
The back names the displayed position of the correct answer and
repeats its text/figure.
repeats its text/figure, and — when the question's number has an
entry in `explanations` — appends a styled English explanation
block at the very end of the back card.
`media` is the `MediaRegistry` for this category; it records
which figure files are actually used so the packager can include
only those.
only those. `explanations` is the dict returned by
`load_explanations()`; `None` is treated as "no explanations".
"""
explanations = explanations or {}
question = item.question
choices, correct_label, correct = randomized_answers(question, seed)
number = html.escape(str(question.get("number", "")))
@@ -390,10 +572,54 @@ def render_question(item, media, seed):
back_parts.append(
f'<div class="af-image af-correct-image">{correct_image}</div>'
)
explanation = explanations.get(str(question.get("number", "")))
if explanation:
back_parts.append(render_explanation(explanation))
back_parts.append("</div>")
return "".join(front_parts), "".join(back_parts), correct_label
def render_explanation(explanation):
"""Render one explanation entry as an HTML block for the card back.
Inline `$...$` LaTeX in the body is rewritten to MathJax via
`text_html()`, same as the rest of the card. Sources that look
like an HTTP(S) URL become a clickable link; anything else (a
citation like "AFuV §16(2)") is rendered as plain text. The
`revision` number stays editorial-only and is never displayed;
`confidence` is also normally hidden, but surfaces as a small
"low confidence" badge in the header when it falls below
`LOW_CONFIDENCE_THRESHOLD` — a hint to the learner that this
explanation needs more work and to a reviewer that it's a
candidate for §8.3 in EXPLANATIONS.md.
"""
body = text_html(explanation["explanation"])
source_html = _source_html(explanation["source"])
badge = ""
if explanation["confidence"] < LOW_CONFIDENCE_THRESHOLD:
badge = (
'<span class="af-explanation-low-confidence" '
'title="This explanation is weakly sourced or incomplete">'
'low confidence</span>'
)
return (
'<div class="af-explanation">'
f'<div class="af-explanation-header">Explanation{badge}</div>'
f'<div class="af-explanation-body">{body}</div>'
f'<div class="af-explanation-source">Source: {source_html}</div>'
'</div>'
)
def _source_html(source):
"""Return HTML for a `source` field: clickable link iff plain URL."""
text = source.strip()
if re.match(r"^https?://\S+$", text):
escaped = html.escape(text, quote=True)
return f'<a href="{escaped}">{escaped}</a>'
return html.escape(text)
def display_path(path):
"""Return the user-facing section path with the top prefix stripped.
@@ -589,14 +815,20 @@ def tags_for_item(item):
# ============================================================================
def build_apkg_for_category(category, edition_dir, out_path, seed, build_epoch):
def build_apkg_for_category(
category, edition_dir, out_path, seed, build_epoch, explanations=None,
):
"""Write one category as an Anki `.apkg` file.
Renders every question to a note, hand-rolls the v11 SQLite
collection, and writes the final ZIP with deterministic
timestamps. Returns a small result dict describing what was
timestamps. `explanations` is the dict returned by
`load_explanations()` (or `None`); when a question's number is
present there, an English explanation block is appended to the
card back. Returns a small result dict describing what was
written (for the CLI summary).
"""
explanations = explanations or {}
tmp_dir = out_path.parent / f".{out_path.name}.tmp"
db_path = tmp_dir / "collection.anki2"
@@ -616,10 +848,15 @@ def build_apkg_for_category(category, edition_dir, out_path, seed, build_epoch):
try:
notes = []
applied_explanations = 0
for ordinal, item in enumerate(category.questions):
front, back, correct_label = render_question(item, media, seed)
front, back, correct_label = render_question(
item, media, seed, explanations,
)
question = item.question
number = str(question.get("number", f"q{ordinal}"))
if number in explanations:
applied_explanations += 1
note_id = stable_id("note", f"{category.slug}:{number}")
card_id = stable_id("card", f"{category.slug}:{number}")
fields = [
@@ -662,6 +899,7 @@ def build_apkg_for_category(category, edition_dir, out_path, seed, build_epoch):
"questions": len(category.questions),
"media": len(media_paths),
"missing_media": sorted(set(media.missing)),
"explanations": applied_explanations,
}
@@ -1169,6 +1407,50 @@ CARD_CSS = """
color: #0b6b3a;
margin-bottom: 0.5rem;
}
.af-explanation {
margin-top: 1.5rem;
padding-top: 0.75rem;
border-top: 1px solid #ccc;
font-family: Georgia, "Times New Roman", serif;
font-size: 15px;
line-height: 1.5;
color: #333;
font-style: italic;
}
.af-explanation-header {
font-family: Arial, sans-serif;
font-style: normal;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 11px;
color: #777;
margin-bottom: 0.4rem;
}
.af-explanation-low-confidence {
display: inline-block;
margin-left: 0.5rem;
padding: 1px 6px;
border-radius: 3px;
background: #fff4d6;
color: #8a5a00;
font-weight: bold;
letter-spacing: 0.04em;
font-size: 10px;
}
.af-explanation-body {
margin-bottom: 0.6rem;
}
.af-explanation-source {
font-family: Arial, sans-serif;
font-style: normal;
font-size: 12px;
color: #666;
}
.af-explanation-source a {
color: #1a73e8;
text-decoration: none;
}
"""
@@ -1199,18 +1481,24 @@ def build_epoch_from_manifest(manifest, override_epoch=None):
) from e
def build_all(data_dir, out_dir, seed, override_epoch=None):
def build_all(data_dir, out_dir, seed, override_epoch=None, explanations_path=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.
Loads the latest fetched catalog and the explanations database,
picks a build epoch, then walks every category writing one
`.apkg` each. Raises `AnkiBuildError` on configuration / catalog
/ explanation-schema problems.
"""
edition_dir, manifest, catalog = load_latest_catalog(data_dir)
build_epoch = build_epoch_from_manifest(manifest, override_epoch)
explanations = load_explanations(
explanations_path if explanations_path is not None
else DEFAULT_EXPLANATIONS_PATH
)
categories = collect_categories(catalog)
if not categories:
raise AnkiBuildError("catalog has no categories")
_check_explanation_keys_against_catalog(explanations, categories)
results = []
for category in categories:
@@ -1222,6 +1510,7 @@ def build_all(data_dir, out_dir, seed, override_epoch=None):
out_path,
seed=seed,
build_epoch=build_epoch,
explanations=explanations,
)
)
return results
@@ -1269,6 +1558,15 @@ def _parse_args(argv):
"derived from manifest.json's fetched_at"
),
)
parser.add_argument(
"--explanations",
type=Path,
default=DEFAULT_EXPLANATIONS_PATH,
help=(
"JSON file with per-question explanations (default: "
"./explanations.json; missing file is treated as empty)"
),
)
return parser.parse_args(argv)
@@ -1281,6 +1579,7 @@ def main(argv=None):
args.out,
seed=args.seed,
override_epoch=args.epoch,
explanations_path=args.explanations,
)
except AnkiBuildError as e:
print(f"error: {e}", file=sys.stderr)
@@ -1294,7 +1593,8 @@ def main(argv=None):
for result in results:
print(
f"wrote {result['path']} "
f"({result['questions']} cards, {result['media']} media files)"
f"({result['questions']} cards, {result['media']} media files, "
f"{result['explanations']} with explanations)"
)
if result["missing_media"]:
print(
+1
View File
@@ -0,0 +1 @@
{}
+232 -11
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),
],
}
],
@@ -114,23 +115,68 @@ class TestAnkiBuild(unittest.TestCase):
self.root = Path(self.tmp.name)
self.data_dir = make_fetched_data(self.root)
self.out_dir = self.root / "anki"
# Hermetic explanations file: empty by default so tests don't
# pick up the real repo's explanations.json via the CLI
# default. Individual tests overwrite this file as needed.
self.explanations_path = self.root / "explanations.json"
self.explanations_path.write_text("{}", encoding="utf-8")
def test_builds_one_apkg_per_top_level_category(self):
results = aa.build_all(self.data_dir, self.out_dir, seed="test-seed")
def _build_all(self):
return aa.build_all(
self.data_dir,
self.out_dir,
seed="test-seed",
explanations_path=self.explanations_path,
)
def test_builds_one_apkg_per_category(self):
results = self._build_all()
paths = sorted(path.name for path in self.out_dir.glob("*.apkg"))
self.assertEqual(
paths,
[
"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):
self._build_all()
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"
self._build_all()
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 +196,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()
@@ -253,15 +299,190 @@ class TestAnkiBuild(unittest.TestCase):
self.assertIn('<rect data-af-white-background="1"', once)
self.assertLess(once.index("<rect"), once.index("<path"))
def test_explanation_is_appended_to_back_when_present(self):
self.explanations_path.write_text(
json.dumps({
"NA101": {
"revision": 1,
"explanation": "Doubling the voltage halves the current for fixed power.",
"source": "https://example.invalid/ohms-law",
"confidence": 8,
}
}),
encoding="utf-8",
)
results = self._build_all()
n_result = next(
r for r in results
if r["deck"] == "Amateurfunk::Technische Kenntnisse::N"
)
self.assertEqual(n_result["explanations"], 1)
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:
fields = [row[0] for row in conn.execute("select flds from notes")]
finally:
conn.close()
joined = "\n".join(fields)
self.assertIn("af-explanation", joined)
self.assertIn("Doubling the voltage halves", joined)
self.assertIn(
'<a href="https://example.invalid/ohms-law">'
'https://example.invalid/ohms-law</a>',
joined,
)
def test_low_confidence_explanation_shows_badge(self):
item = aa.QuestionItem(
question=question("TEST123", "1", "Prompt?", None),
path=("Prüfungsfragen im Prüfungsteil: Technische Kenntnisse", "Leaf"),
)
low = {"TEST123": {
"revision": 1,
"explanation": "Weak guess.",
"source": "TBD",
"confidence": aa.LOW_CONFIDENCE_THRESHOLD - 1,
}}
high = {"TEST123": {
"revision": 1,
"explanation": "Solid reasoning.",
"source": "AFuV §1",
"confidence": aa.LOW_CONFIDENCE_THRESHOLD,
}}
media = aa.MediaRegistry(self.root / "missing")
_f1, back_low, _l1 = aa.render_question(item, media, "seed", low)
_f2, back_high, _l2 = aa.render_question(item, media, "seed", high)
self.assertIn("af-explanation-low-confidence", back_low)
self.assertIn("low confidence", back_low)
self.assertNotIn("af-explanation-low-confidence", back_high)
self.assertNotIn("low confidence", back_high)
def test_missing_explanation_leaves_card_unchanged(self):
self._build_all()
apkg = self.out_dir / "amateurfunk-technische-kenntnisse-e.apkg"
db_path, _media, _names = extract_collection(apkg, self.root / "e")
conn = sqlite3.connect(db_path)
try:
fields = [row[0] for row in conn.execute("select flds from notes")]
finally:
conn.close()
self.assertNotIn("af-explanation", "\n".join(fields))
def test_non_url_source_is_rendered_as_plain_text(self):
item = aa.QuestionItem(
question=question("TEST123", "1", "Prompt?", None),
path=("Prüfungsfragen im Prüfungsteil: Technische Kenntnisse", "Leaf"),
)
explanations = {
"TEST123": {
"revision": 1,
"explanation": "Because $P = U \\cdot I$ and the spec fixes P.",
"source": "AFuV §16(2)",
"confidence": 9,
}
}
_front, back, _label = aa.render_question(
item, aa.MediaRegistry(self.root / "missing"), "seed", explanations,
)
self.assertIn("AFuV §16(2)", back)
self.assertNotIn("<a href=", back)
# Inline LaTeX in the explanation body goes through text_html().
self.assertIn(r"\(P = U \cdot I\)", back)
def test_unknown_explanation_key_fails_the_build(self):
self.explanations_path.write_text(
json.dumps({
"NA10I": { # typo: 'I' instead of '1' — not in the catalog
"revision": 1,
"explanation": "ok",
"source": "ok",
"confidence": 5,
}
}),
encoding="utf-8",
)
with self.assertRaises(aa.AnkiBuildError) as ctx:
self._build_all()
self.assertIn("NA10I", str(ctx.exception))
def test_schema_rejects_bool_in_integer_fields(self):
for bad_field in ("revision", "confidence"):
entry = {
"revision": 1,
"explanation": "ok",
"source": "ok",
"confidence": 5,
}
entry[bad_field] = True
self.explanations_path.write_text(
json.dumps({"NA101": entry}),
encoding="utf-8",
)
with self.assertRaises(aa.AnkiBuildError):
self._build_all()
def test_schema_rejects_extra_fields(self):
self.explanations_path.write_text(
json.dumps({
"NA101": {
"revision": 1,
"explanation": "ok",
"source": "ok",
"confidence": 5,
"author": "claude",
}
}),
encoding="utf-8",
)
with self.assertRaises(aa.AnkiBuildError) as ctx:
self._build_all()
self.assertIn("author", str(ctx.exception))
def test_invalid_explanation_schema_raises_build_error(self):
self.explanations_path.write_text(
json.dumps({
"NA101": {
"revision": 1,
"explanation": "ok",
"source": "ok",
"confidence": 11, # out of range
}
}),
encoding="utf-8",
)
with self.assertRaises(aa.AnkiBuildError):
self._build_all()
def test_missing_explanations_file_is_treated_as_empty(self):
missing = self.root / "does-not-exist.json"
results = aa.build_all(
self.data_dir,
self.out_dir,
seed="test-seed",
explanations_path=missing,
)
self.assertTrue(all(r["explanations"] == 0 for r in results))
def test_main_returns_success(self):
rc = aa.main(["--data", str(self.data_dir), "--out", str(self.out_dir)])
rc = aa.main([
"--data", str(self.data_dir),
"--out", str(self.out_dir),
"--explanations", str(self.explanations_path),
])
self.assertEqual(rc, aa.EXIT_OK)
def test_build_is_byte_deterministic_for_same_input(self):
out_a = self.root / "anki-a"
out_b = self.root / "anki-b"
aa.main(["--data", str(self.data_dir), "--out", str(out_a), "--epoch", "1234567890"])
aa.main(["--data", str(self.data_dir), "--out", str(out_b), "--epoch", "1234567890"])
common = [
"--data", str(self.data_dir),
"--explanations", str(self.explanations_path),
"--epoch", "1234567890",
]
aa.main([*common, "--out", str(out_a)])
aa.main([*common, "--out", str(out_b)])
for first in sorted(out_a.glob("*.apkg")):
second = out_b / first.name