Compare commits

...

3 Commits

Author SHA1 Message Date
imple 5c6fbebd65 Improve and complete all E* (Technische Kenntnisse, class E) explanations
Rewrite every class-E technical explanation to the relaxed, tutoring
standard: explain *why* the correct answer is correct, give the physics,
include formulas in inline LaTeX, and gloss German exam terms with their
common English equivalents (Hub/Frequenzhub = deviation, Sperrkreis =
trap, Raumwelle = sky wave, LC, etc.).

- All 463 E* entries now revised from the real catalog questions
  (answer_a = correct upstream); previously only 216 had been touched.
- Every revision bumped by exactly 1; no confidence ever lowered.
- Sources stay tier-appropriate (50ohm.de for technique, gesetze /
  BImSchV / BEMFV for the legal person-protection items).
- Emphasis uses <u>..</u> (the only sanctioned inline markup), not
  markdown asterisks.
- Fixed three carried-over nits: reverted off-scope VD709, and reset
  EE301/EE306 revisions (1->3) to a correct single +1.

Validated: schema clean, all keys present in the live catalog, balanced
LaTeX, no display math, no stray HTML. Full suite passes (27 tests,
including the live catalog-match test).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:47:42 +02:00
imple 3ce060947d Improve "Kenntnisse von Vorschriften" explanations 2026-06-04 11:59:48 +02:00
imple fdc5932d7d Randomize questions on every Anki regen
Also make explanation section slightly more readable
2026-06-04 11:10:08 +02:00
6 changed files with 1372 additions and 1278 deletions
+5 -2
View File
@@ -83,8 +83,11 @@ data/ ──[Stage 2: amateurfunk_anki.py]──► anki/
block is appended to the back; a "low confidence" badge shows for block is appended to the back; a "low confidence" badge shows for
entries with `confidence < 7`. 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. By
input → byte-identical output across runs. default each build mints a fresh shuffle seed, so answers are
reshuffled every run. Pass `--seed` (and `--epoch`) for the
reproducible-build contract: same catalog + same seed + same
timestamp → byte-identical output across runs.
The Anki design decisions (shuffle seeding, deterministic build epoch, The Anki design decisions (shuffle seeding, deterministic build epoch,
SVG dark-mode handling, schema choices) live in `DESIGN.md` §7. SVG dark-mode handling, schema choices) live in `DESIGN.md` §7.
+15 -10
View File
@@ -463,8 +463,10 @@ These do not block the Stage 1 implementation:
Given the per-edition directory produced by Stage 1, build a set of Given the per-edition directory produced by Stage 1, build a set of
Anki `.apkg` files that turn every catalog question into a flash card. Anki `.apkg` files that turn every catalog question into a flash card.
Same input must produce byte-identical output across runs — this is By default, each build uses a fresh shuffle seed so repeated imports
important so generated decks can be checksummed and cached cleanly. vary the answer order. When `--seed` and `--epoch` are supplied,
the same input must produce byte-identical output across runs — useful
for tests, checksums, and cacheable release builds.
### CLI shape ### CLI shape
@@ -475,9 +477,9 @@ amateurfunk-anki [--data DIR] [--out DIR] [--seed STR] [--epoch INT]
- `--data DIR` — fetch output root (default `./data`). Must contain - `--data DIR` — fetch output root (default `./data`). Must contain
`manifest-latest.json` pointing at a per-edition directory. `manifest-latest.json` pointing at a per-edition directory.
- `--out DIR` — destination for `.apkg` files (default `./anki`). - `--out DIR` — destination for `.apkg` files (default `./anki`).
- `--seed STR` — deterministic seed for answer shuffling. The default - `--seed STR` — deterministic seed for answer shuffling. Omit it
is fixed; changing it produces a different (but still deterministic) for the normal study-deck behavior: a fresh seed is generated for
shuffle. each build, so answers are reshuffled every time decks are rebuilt.
- `--epoch INT` — override the package timestamp epoch. By default we - `--epoch INT` — override the package timestamp epoch. By default we
derive it from the manifest's `fetched_at`; this flag is mainly for derive it from the manifest's `fetched_at`; this flag is mainly for
tests and explicit rebuilds. tests and explicit rebuilds.
@@ -565,8 +567,10 @@ for in-Anki filtering, and harmless besides.
### Determinism ### Determinism
The contract is: same catalog in → same `.apkg` bytes out. The default CLI intentionally is not byte-deterministic because it
Determinism rests on three things: generates a fresh shuffle seed each run. The reproducible-build
contract is: same catalog + same `--seed` + same timestamp inputs →
same `.apkg` bytes out. Determinism rests on three things:
1. **Stable IDs.** `stable_id(namespace, text)` hashes a 1. **Stable IDs.** `stable_id(namespace, text)` hashes a
namespaced key with SHA-1 and squashes into the standard namespaced key with SHA-1 and squashes into the standard
@@ -587,8 +591,9 @@ Determinism rests on three things:
last step, the inner SQLite would be identical but the last step, the inner SQLite would be identical but the
archive's per-entry mtimes would still vary between runs. archive's per-entry mtimes would still vary between runs.
The combined effect: two runs with the same `data/` produce The combined effect: two runs with the same `data/`, `--seed`, and
byte-identical sha256 on each `.apkg`. Verified during review. timestamp inputs produce byte-identical sha256 on each `.apkg`.
Verified during review.
### Rendering decisions worth knowing ### Rendering decisions worth knowing
@@ -636,7 +641,7 @@ byte-identical sha256 on each `.apkg`. Verified during review.
`explanations.json` (CLI: `--explanations`) and, for any `explanations.json` (CLI: `--explanations`) and, for any
question number found there, appends an English explanation question number found there, appends an English explanation
block to the back of the card. The block is styled distinctly block to the back of the card. The block is styled distinctly
(serif italic body, sans-serif metadata, top border) and shows (serif body, sans-serif metadata, top border) and shows
a small "low confidence" badge when the entry's `confidence` a small "low confidence" badge when the entry's `confidence`
field is below `LOW_CONFIDENCE_THRESHOLD` (= 7). `revision` and field is below `LOW_CONFIDENCE_THRESHOLD` (= 7). `revision` and
the raw `confidence` number are editorial-only and never the raw `confidence` number are editorial-only and never
+13 -7
View File
@@ -32,7 +32,7 @@ Every entry MUST have exactly these four fields:
| Field | Type | Constraint | | Field | Type | Constraint |
|---------------|---------|--------------------------------------------| |---------------|---------|--------------------------------------------|
| `revision` | integer | `>= 1`. Starts at `1`, bumps on improvement | | `revision` | integer | `>= 1`. Starts at `1`, bumps on improvement |
| `explanation` | string | Non-empty. **English.** Terse. WHY-focused | | `explanation` | string | Non-empty. **English.** Correct & helpful, WHY-focused |
| `source` | string | Non-empty. URL or citation like `AFuV §16(2)` | | `source` | string | Non-empty. URL or citation like `AFuV §16(2)` |
| `confidence` | integer | `1..10` inclusive. See scale in §5 | | `confidence` | integer | `1..10` inclusive. See scale in §5 |
@@ -82,7 +82,7 @@ modify Python code to ship a new explanation. End-to-end:
inline `$...$` LaTeX rewritten to MathJax `\(...\)`, same as the inline `$...$` LaTeX rewritten to MathJax `\(...\)`, same as the
question text), and a "Source: ..." line. question text), and a "Source: ..." line.
3. The block lands inside `.af-back` at the very end, styled by the 3. The block lands inside `.af-back` at the very end, styled by the
`.af-explanation*` CSS rules — serif italic body, sans-serif `.af-explanation*` CSS rules — serif body, sans-serif
metadata, separated from the answer by a top border. When the metadata, separated from the answer by a top border. When the
entry's `confidence` is **below 7**, a small "low confidence" entry's `confidence` is **below 7**, a small "low confidence"
badge appears next to the **Explanation** header — a hint to the badge appears next to the **Explanation** header — a hint to the
@@ -141,10 +141,15 @@ source citation.
- **Language: English.** The questions and answers stay in German - **Language: English.** The questions and answers stay in German
on the card; the explanation is the one English element. Don't on the card; the explanation is the one English element. Don't
switch. switch.
- **Terse.** Aim for 13 sentences. The card already shows the - **Correct and helpful first; length follows the topic.** There is
question, the correct answer text, and the breadcrumb — the no sentence cap. Use as much room as the question genuinely needs —
explanation should add the missing *why*, not restate any of a one-line arithmetic restatement for a simple lookup, a full
those. worked derivation or a multi-step rule walkthrough where that is
what makes the answer *click*. Don't pad: every sentence should
earn its place by adding correctness, context, or a usable hook.
The card already shows the question, the correct answer text, and
the breadcrumb — the explanation adds the missing *why* and the
understanding needed to get there, not a restatement of those.
- **Lead with the reasoning.** Don't open with "The correct answer - **Lead with the reasoning.** Don't open with "The correct answer
is X because..." — the card already declares the correct answer is X because..." — the card already declares the correct answer
one line above. Go straight to the principle, the formula, the one line above. Go straight to the principle, the formula, the
@@ -406,7 +411,8 @@ Before saving, confirm:
- [ ] Key is the exact catalog `number` (case-sensitive). - [ ] Key is the exact catalog `number` (case-sensitive).
- [ ] All four fields present, correct types, in range. - [ ] All four fields present, correct types, in range.
- [ ] `explanation` is English, terse, WHY-focused. - [ ] `explanation` is English, correct, helpful, WHY-focused — as
long as the topic needs, no longer.
- [ ] `source` is non-empty and as primary as possible. - [ ] `source` is non-empty and as primary as possible.
- [ ] `revision` is correct (1 for new, bumped for improvement). - [ ] `revision` is correct (1 for new, bumped for improvement).
- [ ] `confidence` honestly reflects how well the source supports - [ ] `confidence` honestly reflects how well the source supports
+39 -5
View File
@@ -33,6 +33,7 @@ import json
import os import os
import random import random
import re import re
import secrets
import shutil import shutil
import sqlite3 import sqlite3
import sys import sys
@@ -204,7 +205,7 @@ def load_explanations(path):
{ {
"NA101": { "NA101": {
"revision": 1, "revision": 1,
"explanation": "Terse English text explaining why ...", "explanation": "English text explaining why ...",
"source": "https://... or AFuV §16(2)", "source": "https://... or AFuV §16(2)",
"confidence": 7 "confidence": 7
}, },
@@ -1415,7 +1416,7 @@ CARD_CSS = """
font-size: 15px; font-size: 15px;
line-height: 1.5; line-height: 1.5;
color: #333; color: #333;
font-style: italic; font-style: normal;
} }
.af-explanation-header { .af-explanation-header {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
@@ -1451,6 +1452,28 @@ CARD_CSS = """
color: #1a73e8; color: #1a73e8;
text-decoration: none; text-decoration: none;
} }
.nightMode .af-explanation,
.card.nightMode .af-explanation {
border-top-color: #555;
color: #ddd;
}
.nightMode .af-explanation-header,
.card.nightMode .af-explanation-header {
color: #aaa;
}
.nightMode .af-explanation-source,
.card.nightMode .af-explanation-source {
color: #bbb;
}
.nightMode .af-explanation-source a,
.card.nightMode .af-explanation-source a {
color: #8ab4f8;
}
.nightMode .af-explanation-low-confidence,
.card.nightMode .af-explanation-low-confidence {
background: #4a3510;
color: #ffd77a;
}
""" """
@@ -1481,6 +1504,13 @@ def build_epoch_from_manifest(manifest, override_epoch=None):
) from e ) from e
def resolve_shuffle_seed(seed):
"""Return the explicit shuffle seed or a fresh one for this build."""
if seed is not None:
return seed
return secrets.token_hex(16)
def build_all(data_dir, out_dir, seed, override_epoch=None, explanations_path=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.
@@ -1546,8 +1576,11 @@ def _parse_args(argv):
) )
parser.add_argument( parser.add_argument(
"--seed", "--seed",
default="amateurfunk-anki-v1", default=None,
help="deterministic seed for answer shuffling", help=(
"deterministic seed for answer shuffling; omit to reshuffle "
"answers on every build"
),
) )
parser.add_argument( parser.add_argument(
"--epoch", "--epoch",
@@ -1573,11 +1606,12 @@ def _parse_args(argv):
def main(argv=None): def main(argv=None):
"""Top-level entry point. Returns an exit code; never raises.""" """Top-level entry point. Returns an exit code; never raises."""
args = _parse_args(argv) args = _parse_args(argv)
seed = resolve_shuffle_seed(args.seed)
try: try:
results = build_all( results = build_all(
args.data, args.data,
args.out, args.out,
seed=args.seed, seed=seed,
override_epoch=args.epoch, override_epoch=args.epoch,
explanations_path=args.explanations, explanations_path=args.explanations,
) )
+1254 -1254
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -11,6 +11,7 @@ import tempfile
import unittest import unittest
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from unittest.mock import patch
import amateurfunk_anki as aa import amateurfunk_anki as aa
@@ -108,6 +109,24 @@ def extract_collection(apkg_path: Path, tmp: Path):
return tmp / "collection.anki2", media, names return tmp / "collection.anki2", media, names
class TestResolveShuffleSeed(unittest.TestCase):
"""Unit coverage for the shuffle-seed resolution itself.
The CLI-level test patches `resolve_shuffle_seed` out, so the
actual default-seed behavior is only exercised here.
"""
def test_explicit_seed_is_passed_through(self):
self.assertEqual(aa.resolve_shuffle_seed("my-seed"), "my-seed")
def test_omitted_seed_mints_a_fresh_value_each_call(self):
first = aa.resolve_shuffle_seed(None)
second = aa.resolve_shuffle_seed(None)
self.assertIsInstance(first, str)
self.assertTrue(first)
self.assertNotEqual(first, second)
class TestAnkiBuild(unittest.TestCase): class TestAnkiBuild(unittest.TestCase):
def setUp(self): def setUp(self):
self.tmp = tempfile.TemporaryDirectory() self.tmp = tempfile.TemporaryDirectory()
@@ -479,6 +498,7 @@ class TestAnkiBuild(unittest.TestCase):
common = [ common = [
"--data", str(self.data_dir), "--data", str(self.data_dir),
"--explanations", str(self.explanations_path), "--explanations", str(self.explanations_path),
"--seed", "stable-test-seed",
"--epoch", "1234567890", "--epoch", "1234567890",
] ]
aa.main([*common, "--out", str(out_a)]) aa.main([*common, "--out", str(out_a)])
@@ -488,6 +508,32 @@ class TestAnkiBuild(unittest.TestCase):
second = out_b / first.name second = out_b / first.name
self.assertEqual(first.read_bytes(), second.read_bytes(), first.name) self.assertEqual(first.read_bytes(), second.read_bytes(), first.name)
def test_cli_without_seed_reshuffles_each_build(self):
out_a = self.root / "anki-fresh-a"
out_b = self.root / "anki-fresh-b"
common = [
"--data", str(self.data_dir),
"--explanations", str(self.explanations_path),
"--epoch", "1234567890",
]
with patch(
"amateurfunk_anki.resolve_shuffle_seed",
side_effect=["fresh-a", "fresh-b"],
) as resolve_seed:
aa.main([*common, "--out", str(out_a)])
aa.main([*common, "--out", str(out_b)])
resolved_inputs = [
call.args[0] for call in resolve_seed.call_args_list
]
self.assertEqual(resolved_inputs, [None, None])
byte_equal = [
first.read_bytes() == (out_b / first.name).read_bytes()
for first in sorted(out_a.glob("*.apkg"))
]
self.assertFalse(all(byte_equal))
class TestRealExplanationsFile(unittest.TestCase): class TestRealExplanationsFile(unittest.TestCase):
"""Validate the repo's actual explanations.json against the live catalog. """Validate the repo's actual explanations.json against the live catalog.