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'
Explanation{badge}
' + 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("