Add explanations
This commit is contained in:
+265
-11
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user