Pull explanations from 50ohm.de when available
This commit is contained in:
+121
-10
@@ -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 & condensed from the worked solution on "
|
||||
'<a href="https://50ohm.de/">50ohm.de</a> — '
|
||||
"© 50ohm.de-Autorenteam (AJW-Referat, DARC e. 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 & figures:</b> official question catalog "
|
||||
""Prüfungsfragen zum Erwerb von "
|
||||
"Amateurfunkprüfungsbescheinigungen", 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 "
|
||||
"("Lösungsweg"), 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 "Hilfsmittel" "
|
||||
"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;
|
||||
|
||||
Reference in New Issue
Block a user