Pull explanations from 50ohm.de when available
This commit is contained in:
+17
-7
@@ -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
@@ -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;
|
||||
|
||||
+680
-510
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user