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
+17 -7
View File
@@ -27,7 +27,8 @@ entry is purely additive — no regenerate ceremony beyond
### Per-entry schema
Every entry MUST have exactly these four fields:
Every entry MUST have these four required fields, and MAY carry the one
optional field below:
| Field | Type | Constraint |
|---------------|---------|--------------------------------------------|
@@ -35,13 +36,22 @@ Every entry MUST have exactly these four fields:
| `explanation` | string | Non-empty. **English.** Correct & helpful, WHY-focused |
| `source` | string | Non-empty. URL or citation like `AFuV §16(2)` |
| `confidence` | integer | `1..10` inclusive. See scale in §5 |
| `provenance` | string | *Optional.* Only allowed value: `"50ohm-loesungsweg"` |
Extra keys are rejected by `load_explanations()` — the build fails
with `unknown fields [...]` listing them. The loader is similarly
strict about types: a JSON `true` will not satisfy the integer
contract for `revision` or `confidence`. If you need to track
editorial metadata that isn't shown on the card, propose a schema
change rather than smuggling fields in.
`provenance` records **how the text was produced**, which `source` (a
citation) does not. Set it to `"50ohm-loesungsweg"` only on entries
that are genuinely a translation/condensation of a 50ohm.de worked
solution (`contents/solutions/<ID>.md` in `DARC-e-V/50ohm-contents-dl`).
The build uses it — and *not* the `source` domain — to decide whether
to show the CC BY 4.0 derivative-work credit on the card (CC BY
requires naming the author team and indicating modification). Do **not**
add it just because an entry cites a 50ohm.de study page; merely citing
a page is not a derivative work. Any other value, or any other extra
field, is rejected by `load_explanations()` with
`unknown fields [...]` / `provenance must be one of [...]`. The loader
is also strict about types: a JSON `true` will not satisfy the integer
contract for `revision` or `confidence`. To track other editorial
metadata, propose a schema change rather than smuggling fields in.
Top-level keys (the question numbers) must also match the catalog
exactly. An entry keyed on a number that no live question carries
+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;
+680 -510
View File
File diff suppressed because it is too large Load Diff
+114
View File
@@ -420,6 +420,50 @@ class TestAnkiBuild(unittest.TestCase):
self.assertNotIn("af-explanation-low-confidence", back_high)
self.assertNotIn("low confidence", back_high)
def test_cc_by_credit_gated_on_provenance_not_source_domain(self):
"""The CC BY derivative-work credit must follow explicit
provenance, not the citation domain: an explanation merely
citing a 50ohm.de page is NOT a derivative work and must not
carry the credit, while one marked as derived from a worked
solution must."""
derived = {
"revision": 2,
"explanation": "Worked-solution port.",
"source": "https://50ohm.de/NEA_x.html#AD106",
"confidence": 8,
"provenance": aa.PROVENANCE_50OHM_SOLUTION,
}
cited_only = {
"revision": 1,
"explanation": "Independently written, just cites a study page.",
"source": "https://50ohm.de/NEA_x.html#XX101",
"confidence": 8,
}
derived_html = aa.render_explanation(derived)
cited_html = aa.render_explanation(cited_only)
self.assertIn("af-explanation-credit", derived_html)
self.assertIn("CC BY 4.0", derived_html)
self.assertIn("50ohm.de-Autorenteam", derived_html)
self.assertNotIn("af-explanation-credit", cited_html)
self.assertNotIn("translated", cited_html)
def test_root_deck_description_carries_attribution(self):
self._build_all()
apkg = self.out_dir / "amateurfunk-betriebliche-kenntnisse.apkg"
db_path, _media, _names = extract_collection(apkg, self.root / "b")
conn = sqlite3.connect(db_path)
try:
decks = json.loads(conn.execute("select decks from col").fetchone()[0])
finally:
conn.close()
descs = [d["desc"] for d in decks.values() if d["desc"]]
self.assertEqual(len(descs), 1) # only the root deck carries it
desc = descs[0]
self.assertIn("CC BY 4.0", desc)
self.assertIn("govdata.de/dl-de/by-2-0", desc)
self.assertIn("50ohm.de-Autorenteam", desc)
self.assertIn("Bundesnetzagentur", desc)
def test_missing_explanation_leaves_card_unchanged(self):
self._build_all()
apkg = self.out_dir / "amateurfunk-technische-kenntnisse-e.apkg"
@@ -501,6 +545,48 @@ class TestAnkiBuild(unittest.TestCase):
self._build_all()
self.assertIn("author", str(ctx.exception))
def test_schema_accepts_valid_provenance(self):
path = self.root / "exp_provenance.json"
path.write_text(
json.dumps({"NA101": {
"revision": 1, "explanation": "ok", "source": "ok",
"confidence": 5, "provenance": aa.PROVENANCE_50OHM_SOLUTION,
}}),
encoding="utf-8",
)
loaded = aa.load_explanations(path)
self.assertEqual(
loaded["NA101"]["provenance"], aa.PROVENANCE_50OHM_SOLUTION,
)
def test_schema_rejects_unknown_provenance_value(self):
path = self.root / "exp_badprov.json"
path.write_text(
json.dumps({"NA101": {
"revision": 1, "explanation": "ok", "source": "ok",
"confidence": 5, "provenance": "made-up",
}}),
encoding="utf-8",
)
with self.assertRaises(aa.AnkiBuildError):
aa.load_explanations(path)
def test_schema_rejects_unhashable_provenance(self):
# A malformed but valid-JSON provenance value (list/object) is
# unhashable; the validator must surface AnkiBuildError, never a
# raw TypeError from the set-membership test.
for bad in ([], {}):
path = self.root / "exp_unhashable.json"
path.write_text(
json.dumps({"NA101": {
"revision": 1, "explanation": "ok", "source": "ok",
"confidence": 5, "provenance": bad,
}}),
encoding="utf-8",
)
with self.assertRaises(aa.AnkiBuildError):
aa.load_explanations(path)
def test_invalid_explanation_schema_raises_build_error(self):
self.explanations_path.write_text(
json.dumps({
@@ -617,6 +703,34 @@ class TestRealExplanationsFile(unittest.TestCase):
)
self.assertEqual([], offenders)
def test_safety_distance_block_uses_transmitter_power_not_erp(self):
"""AK106-AK112 must feed the EIRP formula with transmitter power.
The relation is `P_EIRP = P_S · 10^((g_d+2.15-a)/10)` with `P_S`
the transmitter (Sender) power; writing `P_EIRP = P_ERP · 10^…`
double-counts the antenna gain (ERP already includes it). The
upstream 50ohm solutions carry exactly this mislabel, so guard
the whole block against it regressing back in.
"""
repo = Path(__file__).resolve().parent
explanations_path = repo / "explanations.json"
if not explanations_path.exists():
self.skipTest("explanations.json not available")
explanations = aa.load_explanations(explanations_path)
block = [f"AK1{n:02d}" for n in range(6, 13)] # AK106..AK112
for key in block:
if key not in explanations:
continue
body = explanations[key]["explanation"]
self.assertIn(
"P_S", body,
f"{key}: EIRP should be computed from transmitter power P_S",
)
self.assertNotIn(
r"P_\mathrm{EIRP} = P_\mathrm{ERP}", body,
f"{key}: EIRP must not be derived by scaling ERP by the gain",
)
if __name__ == "__main__":
unittest.main()