Add explanations

This commit is contained in:
2026-05-22 16:17:05 +02:00
parent 0d635a8587
commit 27988780cf
7 changed files with 933 additions and 18 deletions
+265 -11
View File
@@ -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'<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>")
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):
"""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(