Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions scripts/check_prose.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
33 changes: 33 additions & 0 deletions tests/test_check_prose.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ def test_lesson_code_fields_are_out_of_scope():
lesson = json.dumps(
{
"title": "Eine Uebung",
"domain": "programming",
"steps": [
{
"exercise": {
Expand Down Expand Up @@ -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
Expand Down
Loading