Randomize questions on every Anki regen

Also make explanation section slightly more readable
This commit is contained in:
2026-06-04 11:10:08 +02:00
parent 7bf6f98ee2
commit fdc5932d7d
5 changed files with 105 additions and 17 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
+1 -1
View File
@@ -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
+38 -4
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
@@ -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,
) )
+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.