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
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.
package it as a `.apkg` ZIP with deterministic timestamps. By
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,
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
Anki `.apkg` files that turn every catalog question into a flash card.
Same input must produce byte-identical output across runs — this is
important so generated decks can be checksummed and cached cleanly.
By default, each build uses a fresh shuffle seed so repeated imports
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
@@ -475,9 +477,9 @@ amateurfunk-anki [--data DIR] [--out DIR] [--seed STR] [--epoch INT]
- `--data DIR` — fetch output root (default `./data`). Must contain
`manifest-latest.json` pointing at a per-edition directory.
- `--out DIR` — destination for `.apkg` files (default `./anki`).
- `--seed STR` — deterministic seed for answer shuffling. The default
is fixed; changing it produces a different (but still deterministic)
shuffle.
- `--seed STR` — deterministic seed for answer shuffling. Omit it
for the normal study-deck behavior: a fresh seed is generated for
each build, so answers are reshuffled every time decks are rebuilt.
- `--epoch INT` — override the package timestamp epoch. By default we
derive it from the manifest's `fetched_at`; this flag is mainly for
tests and explicit rebuilds.
@@ -565,8 +567,10 @@ for in-Anki filtering, and harmless besides.
### Determinism
The contract is: same catalog in → same `.apkg` bytes out.
Determinism rests on three things:
The default CLI intentionally is not byte-deterministic because it
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
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
archive's per-entry mtimes would still vary between runs.
The combined effect: two runs with the same `data/` produce
byte-identical sha256 on each `.apkg`. Verified during review.
The combined effect: two runs with the same `data/`, `--seed`, and
timestamp inputs produce byte-identical sha256 on each `.apkg`.
Verified during review.
### Rendering decisions worth knowing
@@ -636,7 +641,7 @@ byte-identical sha256 on each `.apkg`. Verified during review.
`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
(serif 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
+13 -7
View File
@@ -32,7 +32,7 @@ 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 |
| `explanation` | string | Non-empty. **English.** Correct & helpful, WHY-focused |
| `source` | string | Non-empty. URL or citation like `AFuV §16(2)` |
| `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
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
`.af-explanation*` CSS rules — serif 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
@@ -141,10 +141,15 @@ source citation.
- **Language: English.** The questions and answers stay in German
on the card; the explanation is the one English element. Don't
switch.
- **Terse.** Aim for 13 sentences. The card already shows the
question, the correct answer text, and the breadcrumb — the
explanation should add the missing *why*, not restate any of
those.
- **Correct and helpful first; length follows the topic.** There is
no sentence cap. Use as much room as the question genuinely needs —
a one-line arithmetic restatement for a simple lookup, a full
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
is X because..." — the card already declares the correct answer
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).
- [ ] 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.
- [ ] `revision` is correct (1 for new, bumped for improvement).
- [ ] `confidence` honestly reflects how well the source supports
+39 -5
View File
@@ -33,6 +33,7 @@ import json
import os
import random
import re
import secrets
import shutil
import sqlite3
import sys
@@ -204,7 +205,7 @@ def load_explanations(path):
{
"NA101": {
"revision": 1,
"explanation": "Terse English text explaining why ...",
"explanation": "English text explaining why ...",
"source": "https://... or AFuV §16(2)",
"confidence": 7
},
@@ -1415,7 +1416,7 @@ CARD_CSS = """
font-size: 15px;
line-height: 1.5;
color: #333;
font-style: italic;
font-style: normal;
}
.af-explanation-header {
font-family: Arial, sans-serif;
@@ -1451,6 +1452,28 @@ CARD_CSS = """
color: #1a73e8;
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
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):
"""Build every category's `.apkg` and return their result dicts.
@@ -1546,8 +1576,11 @@ def _parse_args(argv):
)
parser.add_argument(
"--seed",
default="amateurfunk-anki-v1",
help="deterministic seed for answer shuffling",
default=None,
help=(
"deterministic seed for answer shuffling; omit to reshuffle "
"answers on every build"
),
)
parser.add_argument(
"--epoch",
@@ -1573,11 +1606,12 @@ def _parse_args(argv):
def main(argv=None):
"""Top-level entry point. Returns an exit code; never raises."""
args = _parse_args(argv)
seed = resolve_shuffle_seed(args.seed)
try:
results = build_all(
args.data,
args.out,
seed=args.seed,
seed=seed,
override_epoch=args.epoch,
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 zipfile
from pathlib import Path
from unittest.mock import patch
import amateurfunk_anki as aa
@@ -108,6 +109,24 @@ def extract_collection(apkg_path: Path, tmp: Path):
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):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
@@ -479,6 +498,7 @@ class TestAnkiBuild(unittest.TestCase):
common = [
"--data", str(self.data_dir),
"--explanations", str(self.explanations_path),
"--seed", "stable-test-seed",
"--epoch", "1234567890",
]
aa.main([*common, "--out", str(out_a)])
@@ -488,6 +508,32 @@ class TestAnkiBuild(unittest.TestCase):
second = out_b / 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):
"""Validate the repo's actual explanations.json against the live catalog.