diff --git a/CLAUDE.md b/CLAUDE.md
index 672bcb4..91d08f5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -78,7 +78,10 @@ data/ ──[Stage 2: amateurfunk_anki.py]──► anki/
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 (`...`) 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.
@@ -103,6 +106,11 @@ 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
diff --git a/DESIGN.md b/DESIGN.md
index a37976f..05ebae9 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -632,6 +632,22 @@ byte-identical sha256 on each `.apkg`. Verified during review.
the card background. We inject a white `` 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
@@ -667,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 ``, 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
diff --git a/EXPLANATIONS.md b/EXPLANATIONS.md
new file mode 100644
index 0000000..4573679
--- /dev/null
+++ b/EXPLANATIONS.md
@@ -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//fragenkatalog.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 1–3 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.** `...` 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 (3–4) until someone finds a deeper
+hook.
+
+---
+
+## 5. Confidence scale
+
+| Score | Meaning |
+|------:|------------------------------------------------------------------------|
+| 10 | Direct quote / arithmetic restatement from a primary legal source. |
+| 8–9 | Derivation from a well-known formula or law text; no ambiguity. |
+| 6–7 | Reasoning sound but condensed; a reviewer might want one more line. |
+| 4–5 | Educated guess based on context; corroborating source is weak. |
+| 1–3 | 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//__<§>.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 1–6 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 1–4.
+
+### Source ↔ confidence linkage
+
+The §5 confidence scale isn't independent of the tier:
+
+- Tier 1–2 + sound derivation → 9–10 is appropriate.
+- Tier 3–4 → cap at 8 unless the source quotes a primary citation.
+- Tier 5–6 → 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-1–4 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.)
diff --git a/README.md b/README.md
index fa91dd2..48c2c96 100644
--- a/README.md
+++ b/README.md
@@ -50,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
diff --git a/amateurfunk_anki.py b/amateurfunk_anki.py
index 7af5541..97dcb11 100644
--- a/amateurfunk_anki.py
+++ b/amateurfunk_anki.py
@@ -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.
@@ -168,6 +185,121 @@ 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
# ============================================================================
@@ -383,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", "")))
@@ -436,10 +572,54 @@ def render_question(item, media, seed):
back_parts.append(
f'{correct_image}
'
)
+ explanation = explanations.get(str(question.get("number", "")))
+ if explanation:
+ back_parts.append(render_explanation(explanation))
back_parts.append("")
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 = (
+ ''
+ 'low confidence'
+ )
+ return (
+ ''
+ f''
+ f'
{body}
'
+ f'
Source: {source_html}
'
+ '
'
+ )
+
+
+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'{escaped}'
+ return html.escape(text)
+
+
def display_path(path):
"""Return the user-facing section path with the top prefix stripped.
@@ -635,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"
@@ -662,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 = [
@@ -708,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,
}
@@ -1215,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;
+}
"""
@@ -1245,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
- every category 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:
@@ -1268,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
@@ -1315,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)
@@ -1327,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)
@@ -1340,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(
diff --git a/explanations.json b/explanations.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/explanations.json
@@ -0,0 +1 @@
+{}
diff --git a/test_amateurfunk_anki.py b/test_amateurfunk_anki.py
index 804f350..3fdf7ba 100644
--- a/test_amateurfunk_anki.py
+++ b/test_amateurfunk_anki.py
@@ -115,9 +115,22 @@ 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 _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 = aa.build_all(self.data_dir, self.out_dir, seed="test-seed")
+ results = self._build_all()
paths = sorted(path.name for path in self.out_dir.glob("*.apkg"))
self.assertEqual(
paths,
@@ -142,7 +155,7 @@ class TestAnkiBuild(unittest.TestCase):
)
def test_technische_decks_partition_strictly_by_class_field(self):
- aa.build_all(self.data_dir, self.out_dir, seed="test-seed")
+ self._build_all()
per_class_numbers = {}
for letter in ("n", "e", "a"):
apkg = self.out_dir / f"amateurfunk-technische-kenntnisse-{letter}.apkg"
@@ -162,7 +175,7 @@ class TestAnkiBuild(unittest.TestCase):
)
def test_apkg_contains_notes_cards_and_media(self):
- aa.build_all(self.data_dir, self.out_dir, seed="test-seed")
+ self._build_all()
apkg = self.out_dir / "amateurfunk-technische-kenntnisse-n.apkg"
db_path, media, names = extract_collection(apkg, self.root)
@@ -286,15 +299,190 @@ class TestAnkiBuild(unittest.TestCase):
self.assertIn(''
+ 'https://example.invalid/ohms-law',
+ 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("