Pull explanations from 50ohm.de when available

This commit is contained in:
2026-06-24 13:09:52 +02:00
parent 4a14bbb989
commit 3179f54681
4 changed files with 932 additions and 527 deletions
+121 -10
View File
@@ -63,6 +63,17 @@ DEFAULT_EXPLANATIONS_PATH = Path("explanations.json")
# EXPLANATIONS.md for the full editorial contract.
EXPLANATION_FIELDS = ("revision", "explanation", "source", "confidence")
# Optional, validated provenance marker. A `source` URL is only a
# citation; it does NOT establish that the explanation is a derivative
# work. `provenance` records *how the text was produced* so the CC BY
# derivative-work credit is shown only for records that genuinely are
# one. The single recognised value marks an explanation translated and
# condensed from a 50ohm.de worked solution
# (contents/solutions/<ID>.md in DARC-e-V/50ohm-contents-dl).
PROVENANCE_50OHM_SOLUTION = "50ohm-loesungsweg"
ALLOWED_PROVENANCE = frozenset({PROVENANCE_50OHM_SOLUTION})
OPTIONAL_EXPLANATION_FIELDS = ("provenance",)
# 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
@@ -70,6 +81,49 @@ EXPLANATION_FIELDS = ("revision", "explanation", "source", "confidence")
# below 7 is review-worthy").
LOW_CONFIDENCE_THRESHOLD = 7
# ---- Attribution (licensing) ----------------------------------------------
# Two sources are redistributed in these decks and each carries an
# attribution obligation:
# * the questions/figures come from the BNetzA question catalog under
# Datenlizenz Deutschland Namensnennung 2.0 (DL-DE→BY-2.0); and
# * the English explanations are translated and condensed from the
# worked solutions on 50ohm.de, which is CC BY 4.0 and requires both
# naming the author team and indicating that changes were made.
# DECK_ATTRIBUTION goes in every deck's description (the whole-deck
# notice); EXPLANATION_CC_BY_CREDIT is the per-card line appended to
# each 50ohm-derived explanation so the "modified" indication travels
# with the modified text itself.
EXPLANATION_CC_BY_CREDIT = (
"Explanation translated &amp; condensed from the worked solution on "
'<a href="https://50ohm.de/">50ohm.de</a> — '
"© 50ohm.de-Autorenteam (AJW-Referat, DARC e.&nbsp;V.), "
'<a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a>. '
"Question © Bundesnetzagentur, "
'<a href="https://www.govdata.de/dl-de/by-2-0">DL-DE→BY-2.0</a>.'
)
DECK_ATTRIBUTION = (
"Amateurfunk-Lernkarten / amateur-radio exam flashcards.<br><br>"
"<b>Questions &amp; figures:</b> official question catalog "
"&quot;Prüfungsfragen zum Erwerb von "
"Amateurfunkprüfungsbescheinigungen&quot;, Bundesnetzagentur, "
"3. Auflage, März 2024 "
"(https://www.bundesnetzagentur.de/amateurfunk). Licensed under "
"Datenlizenz Deutschland Namensnennung Version 2.0 "
"(https://www.govdata.de/dl-de/by-2-0).<br><br>"
"<b>Explanations:</b> written for this project. Where an explanation "
"is marked as derived from a 50ohm.de worked solution "
"(&quot;Lösungsweg&quot;), that text is © 50ohm.de-Autorenteam, "
"koordiniert durch das AJW-Referat des DARC e. V., licensed under "
"CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/), and has "
"been modified — translated from German and shortened. Other "
"explanations cite their own sources.<br><br>"
"<b>Exam-aid references</b> cite the BNetzA &quot;Hilfsmittel&quot; "
"formula sheet.<br><br>"
"This deck is an independent project and is not endorsed by the "
"Bundesnetzagentur or by the DARC e. V."
)
# 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.
@@ -246,12 +300,15 @@ def load_explanations(path):
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.
Strict shape check: the four documented fields are required and the
optional `provenance` field is allowed (and, if present, must be a
string from `ALLOWED_PROVENANCE`); any *other* extra field is
rejected, so a stray `"note"` or `"author"` field can't accumulate
silently and drift from the documented schema in EXPLANATIONS.md.
Integer fields reject `bool` (which would pass `isinstance(..., int)`
because `bool` subclasses `int` in Python), and every malformed
value — including an unhashable `provenance` — surfaces as an
`AnkiBuildError`, never a raw `TypeError`.
"""
if not isinstance(value, dict):
raise AnkiBuildError(
@@ -262,11 +319,24 @@ def _validate_explanation(key, value):
raise AnkiBuildError(
f"explanation {key!r} missing required fields: {missing}"
)
extra = sorted(set(value) - set(EXPLANATION_FIELDS))
extra = sorted(
set(value) - set(EXPLANATION_FIELDS) - set(OPTIONAL_EXPLANATION_FIELDS)
)
if extra:
raise AnkiBuildError(
f"explanation {key!r}: unknown fields {extra}"
)
if "provenance" in value and (
not isinstance(value["provenance"], str)
or value["provenance"] not in ALLOWED_PROVENANCE
):
# The isinstance check must come first: an unhashable JSON value
# (a list or object) would make the `not in` set test raise a raw
# TypeError instead of the documented AnkiBuildError.
raise AnkiBuildError(
f"explanation {key!r}: provenance must be one of "
f"{sorted(ALLOWED_PROVENANCE)}"
)
if type(value["revision"]) is not int or value["revision"] < 1:
raise AnkiBuildError(
f"explanation {key!r}: revision must be a positive integer"
@@ -619,11 +689,23 @@ def render_explanation(explanation):
'title="This explanation is weakly sourced or incomplete">'
'low confidence</span>'
)
# CC BY 4.0 requires naming the author and indicating modifications.
# Only explanations actually derived from a 50ohm.de worked solution
# carry this credit — gated on the explicit `provenance` marker, NOT
# on the `source` URL (a citation is not provenance; many records
# merely cite a 50ohm study page without being a derivative work).
credit = ""
if explanation.get("provenance") == PROVENANCE_50OHM_SOLUTION:
credit = (
'<div class="af-explanation-credit">'
f'{EXPLANATION_CC_BY_CREDIT}</div>'
)
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>'
f'{credit}'
'</div>'
)
@@ -1218,8 +1300,15 @@ def insert_collection_metadata(conn, decks, cur_deck_id, model_id, now):
package can ship a deck tree, not just one deck. `cur_deck_id` is
the deck made current in `col.conf`.
"""
# The attribution notice goes on the root (current) deck only, so it
# shows once for the package rather than on every sub-deck.
decks_json = {
str(deck_id): deck_json(deck_id, deck_name, now)
str(deck_id): deck_json(
deck_id,
deck_name,
now,
desc=DECK_ATTRIBUTION if deck_id == cur_deck_id else "",
)
for deck_name, deck_id in decks.items()
}
conn.execute(
@@ -1278,11 +1367,14 @@ def collection_conf(deck_id):
}
def deck_json(deck_id, deck_name, now):
def deck_json(deck_id, deck_name, now, desc=""):
"""Return one deck entry for `col.decks`.
The `Amateurfunk::Foo` naming gives the user a single top-level
Amateurfunk node in Anki's deck browser with three child decks.
`desc` is the deck description shown in Anki's deck overview; we use
it on the root deck to carry the source-attribution notice (see
DECK_ATTRIBUTION).
"""
return {
"id": deck_id,
@@ -1297,7 +1389,7 @@ def deck_json(deck_id, deck_name, now):
"browserCollapsed": False,
"dyn": 0,
"conf": 1,
"desc": "",
"desc": desc,
"extendNew": 0,
"extendRev": 0,
}
@@ -1502,6 +1594,17 @@ CARD_CSS = """
color: #1a73e8;
text-decoration: none;
}
.af-explanation-credit {
font-family: Arial, sans-serif;
font-style: normal;
font-size: 11px;
color: #999;
margin-top: 0.35rem;
}
.af-explanation-credit a {
color: #1a73e8;
text-decoration: none;
}
.nightMode .af-explanation,
.card.nightMode .af-explanation {
border-top-color: #555;
@@ -1519,6 +1622,14 @@ CARD_CSS = """
.card.nightMode .af-explanation-source a {
color: #8ab4f8;
}
.nightMode .af-explanation-credit,
.card.nightMode .af-explanation-credit {
color: #999;
}
.nightMode .af-explanation-credit a,
.card.nightMode .af-explanation-credit a {
color: #8ab4f8;
}
.nightMode .af-explanation-low-confidence,
.card.nightMode .af-explanation-low-confidence {
background: #4a3510;