From ed686bf99c9482d262d97ffe661bcb2a8bd121d0 Mon Sep 17 00:00:00 2001 From: Asterios Raptis Date: Mon, 12 Oct 2026 19:05:50 +0200 Subject: [PATCH] chore(tooling): the prose gate reads a cloze sentence as code only in a programming lesson Byte-identical copy of the template-owned scripts/check_prose.py and tests/test_check_prose.py from adaptive-learner-content-template#105. A cloze sentence outside programming (also in a lesson without a domain) now gets the umlaut check; passage, tokens and code stay code. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/check_prose.py | 28 +++++++++++++++++++++------- tests/test_check_prose.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/scripts/check_prose.py b/scripts/check_prose.py index f75b20b..5f42916 100755 --- a/scripts/check_prose.py +++ b/scripts/check_prose.py @@ -36,7 +36,8 @@ Code is exempt from the umlaut check, and only from that one. An identifier is spelled by whoever wrote it: "laeuft" as a variable name is a choice, "laeuft" in a sentence is a misspelling. In a lesson file that means the code-bearing -fields (``passage``, ``sentence``, ``tokens``, ``code``), a string ending in a +fields (``passage``, ``tokens``, ``code``, and ``sentence`` in a programming +lesson; elsewhere a cloze sentence is prose), a string ending in a semicolon (a code line offered as an option) and the fenced blocks inside a theory body; in Markdown it means the fenced blocks. @@ -185,6 +186,12 @@ def _pair_decisions(stem: str, correction: str) -> dict[int, str]: "expected_output", } | MACHINE_KEYS +# A cloze sentence is code only in a programming lesson. Everywhere else it is +# a German or foreign sentence the learner reads, and treating it as code let +# "heisst" and "Massstab" through (content-test, alc-books, 2026-10-12). A +# lesson without a domain gets the stricter reading. +PROSE_CODE_KEYS = CODE_KEYS - {"sentence"} + # This gate and its test hold the misspellings on purpose. UMLAUT_EXEMPT = ( @@ -322,7 +329,7 @@ def prose_segments(path: str, text: str) -> list[tuple[int, str]]: except ValueError: return list(enumerate(text.splitlines(), start=1)) collected: list[str] = [] - _walk_json(document, False, collected) + _walk_json(document, False, collected, _code_keys(document)) lines = text.splitlines() segments = [] for value in collected: @@ -370,23 +377,30 @@ def _yaml_segments(text: str) -> list[tuple[int, str]]: return segments -def _walk_json(node, in_code: bool, out: list[str]) -> None: +def _code_keys(document) -> set[str]: + """The code-bearing fields of a lesson file: the cloze sentence counts + only in a programming lesson.""" + is_programming = isinstance(document, dict) and document.get("domain") == "programming" + return CODE_KEYS if is_programming else PROSE_CODE_KEYS + + +def _walk_json(node, in_code: bool, out: list[str], code_keys: set[str] = CODE_KEYS) -> None: if isinstance(node, dict): # An inline example is prose when it is a sample sentence and code when # it declares a language; the field name alone cannot tell them apart. example_is_code = bool(node.get("language")) and "content" in node for key, value in node.items(): - child_is_code = in_code or key in CODE_KEYS or (example_is_code and key == "content") + child_is_code = in_code or key in code_keys or (example_is_code and key == "content") if key == "accept" and isinstance(value, list): # The app shows accept[0] as the solution; the later entries # only catch what a learner types, an ASCII spelling for a # keyboard without umlauts among them. - _walk_json(value[:1], child_is_code, out) + _walk_json(value[:1], child_is_code, out, code_keys) continue - _walk_json(value, child_is_code, out) + _walk_json(value, child_is_code, out, code_keys) elif isinstance(node, list): for value in node: - _walk_json(value, in_code, out) + _walk_json(value, in_code, out, code_keys) elif isinstance(node, str) and not in_code and not node.rstrip().endswith(";"): # A string ending in ";" is a code statement (a select cloze offering # code lines); prose does not end a field with a semicolon. diff --git a/tests/test_check_prose.py b/tests/test_check_prose.py index 10fc9a5..7ac5a3d 100644 --- a/tests/test_check_prose.py +++ b/tests/test_check_prose.py @@ -112,6 +112,7 @@ def test_lesson_code_fields_are_out_of_scope(): lesson = json.dumps( { "title": "Eine Uebung", + "domain": "programming", "steps": [ { "exercise": { @@ -293,6 +294,38 @@ def _scanned(path: str, text: str) -> str: return " ".join(segment for _, segment in check_prose.prose_segments(path, text)) +def _cloze_lesson(sentence: str, domain: str | None) -> str: + lesson = {"title": "T", "steps": [{"exercise": {"sentence": sentence, "ext_payload": {"passage": "x = zurueck"}}}]} + if domain is not None: + lesson["domain"] = domain + return json.dumps(lesson, ensure_ascii=False) + + +def test_a_cloze_sentence_outside_programming_is_prose(): + # The cloze sentence used to count as code everywhere, for programming + # clozes; a German sentence in a knowledge set then escaped the umlaut + # check ("Die Bestehensgrenze heisst im Payload ___.", content-test). + scanned = _scanned("lesson.json", _cloze_lesson("Die Grenze heisst ___.", "knowledge")) + assert "Die Grenze heisst ___." in scanned + + +def test_a_cloze_sentence_in_a_programming_lesson_stays_code(): + scanned = _scanned("lesson.json", _cloze_lesson("ergebnis = laeuft(___)", "programming")) + assert "laeuft" not in scanned + + +def test_a_lesson_without_a_domain_scans_its_cloze_sentences(): + # No domain is not programming: the stricter reading applies. + scanned = _scanned("lesson.json", _cloze_lesson("Die Grenze heisst ___.", None)) + assert "heisst" in scanned + + +def test_only_the_cloze_sentence_follows_the_domain(): + # passage, tokens and code stay code in every domain. + scanned = _scanned("lesson.json", _cloze_lesson("Die Grenze heisst ___.", "knowledge")) + assert "zurueck" not in scanned + + def test_accept_entries_after_the_first_are_typing_variants(): # The app shows accept[0] as the solution; the later entries only catch # what a learner types, and an ASCII spelling is there on purpose for a