Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
27988780cf
|
|||
|
0d635a8587
|
@@ -43,7 +43,9 @@ BNetzA ZIP ──[Stage 1: amateurfunk_fetch.py]──► data/<slug>/
|
|||||||
└── manifest.json
|
└── manifest.json
|
||||||
|
|
||||||
data/ ──[Stage 2: amateurfunk_anki.py]──► anki/
|
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-betriebliche-kenntnisse.apkg
|
||||||
└── amateurfunk-kenntnisse-von-vorschriften.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
|
1. Read the latest edition from `data/` (following
|
||||||
`manifest-latest.json` to a per-edition directory).
|
`manifest-latest.json` to a per-edition directory).
|
||||||
2. Split the catalog into three categories — one per top-level
|
2. Split the catalog into five categories. Betriebliche and
|
||||||
Prüfungsteil (Technische / Betriebliche / Vorschriften). The
|
Vorschriften get one deck each (shared across every candidate).
|
||||||
license-class axis is mapped into tags, not separate packages.
|
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
|
3. Render every question as an Anki note: shuffled A/B/C/D choices on
|
||||||
the front, the displayed position of the correct answer on the
|
the front, the displayed position of the correct answer on the
|
||||||
back. Inline `$...$` LaTeX is converted to MathJax `\(...\)`
|
back. Inline `$...$` LaTeX is converted to MathJax `\(...\)`
|
||||||
delimiters; the catalog's safe inline markup (`<u>...</u>`) is
|
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
|
4. Hand-roll the v11 Anki collection (SQLite + JSON config) and
|
||||||
package it as a `.apkg` ZIP with deterministic timestamps. Same
|
package it as a `.apkg` ZIP with deterministic timestamps. Same
|
||||||
input → byte-identical output across runs.
|
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
|
## 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
|
- Start from `DESIGN.md` — it has the JSON schema, the question/answer
|
||||||
conventions (answer A is always correct upstream → consumers shuffle
|
conventions (answer A is always correct upstream → consumers shuffle
|
||||||
before display), the LaTeX-in-questions caveat, the exam-structure
|
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
|
- Do not invent new download URLs; the ones in `DESIGN.md` were
|
||||||
verified against the live BNetzA site.
|
verified against the live BNetzA site.
|
||||||
- When BNetzA publishes a new edition, expect a new
|
- When BNetzA publishes a new edition, expect a new
|
||||||
|
|||||||
@@ -491,28 +491,43 @@ output artifacts.
|
|||||||
|
|
||||||
```
|
```
|
||||||
anki/
|
anki/
|
||||||
amateurfunk-technische-kenntnisse.apkg (~2.5 MB, 1374 cards)
|
amateurfunk-technische-kenntnisse-n.apkg (195 cards)
|
||||||
amateurfunk-betriebliche-kenntnisse.apkg (~130 KB, 172 cards)
|
amateurfunk-technische-kenntnisse-e.apkg (463 cards)
|
||||||
amateurfunk-kenntnisse-von-vorschriften.apkg (~75 KB, 204 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
|
Five `.apkg` files. Betriebliche and Vorschriften are shared across
|
||||||
study pools from §3 axis 1: Technische = 1374 (cumulative N+E+A);
|
every candidate (class-1-only in the data per §3 axis 2) and stay as
|
||||||
Betriebliche = 172 (shared across all candidates); Vorschriften = 204
|
one deck each. Technische is fanned out per license class using a
|
||||||
(also shared). License class is conveyed via tags
|
**strict equality split** on the question's `class` field — class-1
|
||||||
(`klasse-N` / `klasse-E` / `klasse-A`), not separate packages —
|
questions land in the N deck only, class-2 in E only, class-3 in A
|
||||||
Anki users browse decks by topic and filter by class.
|
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
|
### Steps
|
||||||
|
|
||||||
1. **Load** the latest catalog: follow `manifest-latest.json` to a
|
1. **Load** the latest catalog: follow `manifest-latest.json` to a
|
||||||
per-edition directory, read the catalog JSON, the per-edition
|
per-edition directory, read the catalog JSON, the per-edition
|
||||||
`manifest.json`, and index the `svgs/` folder.
|
`manifest.json`, and index the `svgs/` folder.
|
||||||
2. **Categorize** the question tree into three `Category` objects,
|
2. **Categorize** the question tree into five `Category` objects.
|
||||||
one per top-level Prüfungsteil. Each category carries the flat
|
Betriebliche and Vorschriften get one each. Technische is
|
||||||
list of every question that lives anywhere under it, along with
|
additionally split into three sub-categories (one per license
|
||||||
each question's path through the section tree (used for the card
|
class) via a strict equality match on the question's `class`
|
||||||
breadcrumb and the path tags).
|
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:
|
3. **For each category, render every question** as an Anki note:
|
||||||
- Compute a stable per-question seed from `--seed` + question
|
- Compute a stable per-question seed from `--seed` + question
|
||||||
number; shuffle the A/B/C/D choices using it. The displayed
|
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 ß
|
- **Slug for filenames.** `slugify()` maps German umlauts and ß
|
||||||
to ASCII digraphs before NFKD-normalizing the rest, then keeps
|
to ASCII digraphs before NFKD-normalizing the rest, then keeps
|
||||||
only `[a-z0-9-]`. Yields readable, sortable filenames
|
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
|
- **Tags.** Each note carries `klasse-N|E|A` plus
|
||||||
`pfad-<slugified-section>` for every section level below the
|
`pfad-<slugified-section>` for every section level below the
|
||||||
top-level Prüfungsteil. Per-question number tags are
|
top-level Prüfungsteil. The `klasse-*` tag is redundant within
|
||||||
deliberately *not* emitted — they would create ~1750 singletons
|
the three Technische decks (every card already shares one class)
|
||||||
in Anki's tag tree, and the number is already in the dedicated
|
but is still emitted there for uniformity, and remains the
|
||||||
`Number` field for search.
|
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
|
- **Breadcrumb consistency.** The visible card breadcrumb and the
|
||||||
stored `Path` field both go through one `display_path()`
|
stored `Path` field both go through one `display_path()`
|
||||||
helper, so they never drift. The boilerplate
|
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
|
the card background. We inject a white `<rect>` as the first
|
||||||
painted element when packaging the SVG into the `.apkg`. The
|
painted element when packaging the SVG into the `.apkg`. The
|
||||||
on-disk extracted files are left untouched.
|
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
|
### 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
|
verify the output — tests open the `.apkg` ZIP directly, parse
|
||||||
the embedded SQLite, and check structural invariants.
|
the embedded SQLite, and check structural invariants.
|
||||||
|
|
||||||
- Top-level structure: one `.apkg` per Prüfungsteil; expected
|
- Top-level structure: five `.apkg` files (Betriebliche +
|
||||||
filename slugs.
|
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.
|
- Note count and field shape per category.
|
||||||
- Tag mapping: a class-1 question carries `klasse-N`, no
|
- Tag mapping: a class-1 question carries `klasse-N`, no
|
||||||
`nummer-*` tags.
|
`nummer-*` tags.
|
||||||
@@ -643,6 +683,13 @@ the embedded SQLite, and check structural invariants.
|
|||||||
- Deterministic build: two consecutive builds against the same
|
- Deterministic build: two consecutive builds against the same
|
||||||
catalog with a fixed `--epoch` produce byte-identical `.apkg`
|
catalog with a fixed `--epoch` produce byte-identical `.apkg`
|
||||||
files.
|
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
|
### What we deliberately do NOT do in Stage 2
|
||||||
|
|
||||||
|
|||||||
+425
@@ -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 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.** `<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 (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/<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 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.)
|
||||||
@@ -13,9 +13,12 @@ make test # run both test suites
|
|||||||
make clean # remove data/ and anki/
|
make clean # remove data/ and anki/
|
||||||
```
|
```
|
||||||
|
|
||||||
Output: three `.apkg` files under `anki/`, one per Prüfungsteil
|
Output: five `.apkg` files under `anki/`. Betriebliche and
|
||||||
(Technische / Betriebliche / Vorschriften). Import them into Anki —
|
Vorschriften get one deck each (shared across all license classes);
|
||||||
re-importing a newer build preserves your review history.
|
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
|
## 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.
|
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
|
## More
|
||||||
|
|
||||||
- `CLAUDE.md` — project orientation, pipeline overview.
|
- `CLAUDE.md` — project orientation, pipeline overview.
|
||||||
- `DESIGN.md` — source-discovery notes, JSON schema, per-stage
|
- `DESIGN.md` — source-discovery notes, JSON schema, per-stage
|
||||||
design contracts.
|
design contracts.
|
||||||
|
- `EXPLANATIONS.md` — schema + workflows for the explanations
|
||||||
|
database.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+322
-22
@@ -7,10 +7,10 @@ script does:
|
|||||||
1. Read the per-edition directory written by `amateurfunk_fetch.py`
|
1. Read the per-edition directory written by `amateurfunk_fetch.py`
|
||||||
(the JSON catalog, the `svgs/` folder, and the per-edition
|
(the JSON catalog, the `svgs/` folder, and the per-edition
|
||||||
`manifest.json`).
|
`manifest.json`).
|
||||||
2. Split the catalog into three categories — one per top-level
|
2. Split the catalog into five categories. Betriebliche and
|
||||||
Prüfungsteil: Technische / Betriebliche / Vorschriften. (The
|
Vorschriften get one each. Technische is additionally fanned
|
||||||
license-class axis is mapped into tags, not into separate
|
out per license class into three decks (N / E / A) via a
|
||||||
packages.)
|
strict equality split on the question's `class` field.
|
||||||
3. For each category, render every question as an Anki note: front
|
3. For each category, render every question as an Anki note: front
|
||||||
shows the (shuffled) A/B/C/D choices, back names the displayed
|
shows the (shuffled) A/B/C/D choices, back names the displayed
|
||||||
position of the correct answer.
|
position of the correct answer.
|
||||||
@@ -20,8 +20,8 @@ script does:
|
|||||||
|
|
||||||
The script is intentionally a single file with stdlib only. Readability
|
The script is intentionally a single file with stdlib only. Readability
|
||||||
beats cleverness here — most of the bytes below are docstrings and
|
beats cleverness here — most of the bytes below are docstrings and
|
||||||
comments. Performance is a non-goal (we build three small decks once
|
comments. Performance is a non-goal (we build a handful of small
|
||||||
per upstream edition).
|
decks once per upstream edition).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -52,6 +52,23 @@ DEFAULT_DATA_DIR = Path("data")
|
|||||||
# Default destination for the generated `.apkg` files.
|
# Default destination for the generated `.apkg` files.
|
||||||
DEFAULT_OUT_DIR = Path("anki")
|
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
|
# Exit codes. The builder is much simpler than the fetcher — there is
|
||||||
# no "operator must resolve local state" case here, so two codes are
|
# no "operator must resolve local state" case here, so two codes are
|
||||||
# enough.
|
# enough.
|
||||||
@@ -75,6 +92,16 @@ TOP_LEVEL_PREFIX = "Prüfungsfragen im Prüfungsteil: "
|
|||||||
# search for.
|
# search for.
|
||||||
CLASS_TAGS = {"1": "N", "2": "E", "3": "A"}
|
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
|
# Fallback build epoch if neither the manifest nor `--epoch` supplies
|
||||||
# one. Picked as 0 so missing-metadata builds are still deterministic
|
# one. Picked as 0 so missing-metadata builds are still deterministic
|
||||||
# and obviously wrong (timestamps would all show 1970).
|
# and obviously wrong (timestamps would all show 1970).
|
||||||
@@ -158,18 +185,139 @@ def load_latest_catalog(data_dir):
|
|||||||
return edition_dir, manifest, catalog
|
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
|
# Category collection
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
def collect_categories(catalog):
|
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
|
Per DESIGN.md §3 axis 1 the catalog has three top-level sections
|
||||||
are exactly the three Prüfungsteile. We walk each subtree and
|
(the three Prüfungsteile). Technische Kenntnisse is additionally
|
||||||
flatten every question it contains, recording the section path so
|
fanned out per license class (N/E/A) — see DESIGN.md §7 "Output
|
||||||
cards can show a breadcrumb and we can emit path-shaped tags.
|
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")
|
sections = catalog.get("sections")
|
||||||
if not isinstance(sections, list):
|
if not isinstance(sections, list):
|
||||||
@@ -181,6 +329,9 @@ def collect_categories(catalog):
|
|||||||
questions = []
|
questions = []
|
||||||
_collect_questions(section, (title,), questions)
|
_collect_questions(section, (title,), questions)
|
||||||
short_title = _short_category_title(title)
|
short_title = _short_category_title(title)
|
||||||
|
if short_title == TECHNISCHE_SHORT_TITLE:
|
||||||
|
categories.extend(_split_by_class(title, short_title, questions))
|
||||||
|
else:
|
||||||
categories.append(
|
categories.append(
|
||||||
Category(
|
Category(
|
||||||
title=title,
|
title=title,
|
||||||
@@ -192,6 +343,33 @@ def collect_categories(catalog):
|
|||||||
return categories
|
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):
|
def _collect_questions(section, path, out):
|
||||||
"""Recurse into a section node and append every question to `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)`.
|
"""Render one question as `(front_html, back_html, correct_label)`.
|
||||||
|
|
||||||
The front shows the question stem, an optional figure, and the
|
The front shows the question stem, an optional figure, and the
|
||||||
four shuffled answer choices as an ordered list with `type="A"`.
|
four shuffled answer choices as an ordered list with `type="A"`.
|
||||||
The back names the displayed position of the correct answer and
|
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
|
`media` is the `MediaRegistry` for this category; it records
|
||||||
which figure files are actually used so the packager can include
|
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
|
question = item.question
|
||||||
choices, correct_label, correct = randomized_answers(question, seed)
|
choices, correct_label, correct = randomized_answers(question, seed)
|
||||||
number = html.escape(str(question.get("number", "")))
|
number = html.escape(str(question.get("number", "")))
|
||||||
@@ -390,10 +572,54 @@ def render_question(item, media, seed):
|
|||||||
back_parts.append(
|
back_parts.append(
|
||||||
f'<div class="af-image af-correct-image">{correct_image}</div>'
|
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>")
|
back_parts.append("</div>")
|
||||||
return "".join(front_parts), "".join(back_parts), correct_label
|
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):
|
def display_path(path):
|
||||||
"""Return the user-facing section path with the top prefix stripped.
|
"""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.
|
"""Write one category as an Anki `.apkg` file.
|
||||||
|
|
||||||
Renders every question to a note, hand-rolls the v11 SQLite
|
Renders every question to a note, hand-rolls the v11 SQLite
|
||||||
collection, and writes the final ZIP with deterministic
|
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).
|
written (for the CLI summary).
|
||||||
"""
|
"""
|
||||||
|
explanations = explanations or {}
|
||||||
tmp_dir = out_path.parent / f".{out_path.name}.tmp"
|
tmp_dir = out_path.parent / f".{out_path.name}.tmp"
|
||||||
db_path = tmp_dir / "collection.anki2"
|
db_path = tmp_dir / "collection.anki2"
|
||||||
|
|
||||||
@@ -616,10 +848,15 @@ def build_apkg_for_category(category, edition_dir, out_path, seed, build_epoch):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
notes = []
|
notes = []
|
||||||
|
applied_explanations = 0
|
||||||
for ordinal, item in enumerate(category.questions):
|
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
|
question = item.question
|
||||||
number = str(question.get("number", f"q{ordinal}"))
|
number = str(question.get("number", f"q{ordinal}"))
|
||||||
|
if number in explanations:
|
||||||
|
applied_explanations += 1
|
||||||
note_id = stable_id("note", f"{category.slug}:{number}")
|
note_id = stable_id("note", f"{category.slug}:{number}")
|
||||||
card_id = stable_id("card", f"{category.slug}:{number}")
|
card_id = stable_id("card", f"{category.slug}:{number}")
|
||||||
fields = [
|
fields = [
|
||||||
@@ -662,6 +899,7 @@ def build_apkg_for_category(category, edition_dir, out_path, seed, build_epoch):
|
|||||||
"questions": len(category.questions),
|
"questions": len(category.questions),
|
||||||
"media": len(media_paths),
|
"media": len(media_paths),
|
||||||
"missing_media": sorted(set(media.missing)),
|
"missing_media": sorted(set(media.missing)),
|
||||||
|
"explanations": applied_explanations,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1169,6 +1407,50 @@ CARD_CSS = """
|
|||||||
color: #0b6b3a;
|
color: #0b6b3a;
|
||||||
margin-bottom: 0.5rem;
|
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
|
) 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.
|
"""Build every category's `.apkg` and return their result dicts.
|
||||||
|
|
||||||
Loads the latest fetched catalog, picks a build epoch, then walks
|
Loads the latest fetched catalog and the explanations database,
|
||||||
the three top-level categories writing one `.apkg` each. Raises
|
picks a build epoch, then walks every category writing one
|
||||||
`AnkiBuildError` on configuration / catalog problems.
|
`.apkg` each. Raises `AnkiBuildError` on configuration / catalog
|
||||||
|
/ explanation-schema problems.
|
||||||
"""
|
"""
|
||||||
edition_dir, manifest, catalog = load_latest_catalog(data_dir)
|
edition_dir, manifest, catalog = load_latest_catalog(data_dir)
|
||||||
build_epoch = build_epoch_from_manifest(manifest, override_epoch)
|
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)
|
categories = collect_categories(catalog)
|
||||||
if not categories:
|
if not categories:
|
||||||
raise AnkiBuildError("catalog has no categories")
|
raise AnkiBuildError("catalog has no categories")
|
||||||
|
_check_explanation_keys_against_catalog(explanations, categories)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for category in categories:
|
for category in categories:
|
||||||
@@ -1222,6 +1510,7 @@ def build_all(data_dir, out_dir, seed, override_epoch=None):
|
|||||||
out_path,
|
out_path,
|
||||||
seed=seed,
|
seed=seed,
|
||||||
build_epoch=build_epoch,
|
build_epoch=build_epoch,
|
||||||
|
explanations=explanations,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return results
|
return results
|
||||||
@@ -1269,6 +1558,15 @@ def _parse_args(argv):
|
|||||||
"derived from manifest.json's fetched_at"
|
"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)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
@@ -1281,6 +1579,7 @@ def main(argv=None):
|
|||||||
args.out,
|
args.out,
|
||||||
seed=args.seed,
|
seed=args.seed,
|
||||||
override_epoch=args.epoch,
|
override_epoch=args.epoch,
|
||||||
|
explanations_path=args.explanations,
|
||||||
)
|
)
|
||||||
except AnkiBuildError as e:
|
except AnkiBuildError as e:
|
||||||
print(f"error: {e}", file=sys.stderr)
|
print(f"error: {e}", file=sys.stderr)
|
||||||
@@ -1294,7 +1593,8 @@ def main(argv=None):
|
|||||||
for result in results:
|
for result in results:
|
||||||
print(
|
print(
|
||||||
f"wrote {result['path']} "
|
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"]:
|
if result["missing_media"]:
|
||||||
print(
|
print(
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
+232
-11
@@ -32,6 +32,7 @@ def make_catalog():
|
|||||||
"questions": [
|
"questions": [
|
||||||
question("NA101", "1", "Was ist A?", "NA101_q"),
|
question("NA101", "1", "Was ist A?", "NA101_q"),
|
||||||
question("EA202", "2", "Was ist E?", None),
|
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.root = Path(self.tmp.name)
|
||||||
self.data_dir = make_fetched_data(self.root)
|
self.data_dir = make_fetched_data(self.root)
|
||||||
self.out_dir = self.root / "anki"
|
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):
|
def _build_all(self):
|
||||||
results = aa.build_all(self.data_dir, self.out_dir, seed="test-seed")
|
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"))
|
paths = sorted(path.name for path in self.out_dir.glob("*.apkg"))
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
paths,
|
paths,
|
||||||
[
|
[
|
||||||
"amateurfunk-betriebliche-kenntnisse.apkg",
|
"amateurfunk-betriebliche-kenntnisse.apkg",
|
||||||
"amateurfunk-kenntnisse-von-vorschriften.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):
|
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.apkg"
|
apkg = self.out_dir / "amateurfunk-technische-kenntnisse-n.apkg"
|
||||||
db_path, media, names = extract_collection(apkg, self.root)
|
db_path, media, names = extract_collection(apkg, self.root)
|
||||||
|
|
||||||
self.assertIn("collection.anki2", names)
|
self.assertIn("collection.anki2", names)
|
||||||
@@ -150,8 +196,8 @@ class TestAnkiBuild(unittest.TestCase):
|
|||||||
try:
|
try:
|
||||||
note_count = conn.execute("select count(*) from notes").fetchone()[0]
|
note_count = conn.execute("select count(*) from notes").fetchone()[0]
|
||||||
card_count = conn.execute("select count(*) from cards").fetchone()[0]
|
card_count = conn.execute("select count(*) from cards").fetchone()[0]
|
||||||
self.assertEqual(note_count, 2)
|
self.assertEqual(note_count, 1)
|
||||||
self.assertEqual(card_count, 2)
|
self.assertEqual(card_count, 1)
|
||||||
fields = [row[0] for row in conn.execute("select flds from notes")]
|
fields = [row[0] for row in conn.execute("select flds from notes")]
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -253,15 +299,190 @@ class TestAnkiBuild(unittest.TestCase):
|
|||||||
self.assertIn('<rect data-af-white-background="1"', once)
|
self.assertIn('<rect data-af-white-background="1"', once)
|
||||||
self.assertLess(once.index("<rect"), once.index("<path"))
|
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):
|
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)
|
self.assertEqual(rc, aa.EXIT_OK)
|
||||||
|
|
||||||
def test_build_is_byte_deterministic_for_same_input(self):
|
def test_build_is_byte_deterministic_for_same_input(self):
|
||||||
out_a = self.root / "anki-a"
|
out_a = self.root / "anki-a"
|
||||||
out_b = self.root / "anki-b"
|
out_b = self.root / "anki-b"
|
||||||
aa.main(["--data", str(self.data_dir), "--out", str(out_a), "--epoch", "1234567890"])
|
common = [
|
||||||
aa.main(["--data", str(self.data_dir), "--out", str(out_b), "--epoch", "1234567890"])
|
"--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")):
|
for first in sorted(out_a.glob("*.apkg")):
|
||||||
second = out_b / first.name
|
second = out_b / first.name
|
||||||
|
|||||||
Reference in New Issue
Block a user