From 3d5095a9eca8522fd6191bc715436a7ba52512b7 Mon Sep 17 00:00:00 2001 From: Aadarsh Padiyath Date: Thu, 8 Oct 2026 13:35:52 -0400 Subject: [PATCH 1/3] Accept four or more "=" before a question's test code in CodeTailor CodeTailor found a book-authored question's unit tests by splitting its activecode source on exactly "====". activecode.js accepts "=====" and longer, which older books use, and for those questions the split left a stray "=" at the top of the tests. Every generated solution then failed with a SyntaxError and CodeTailor silently fell back to the static example instead of personalizing help. Split on a line of four or more "=", as activecode.js does. Co-Authored-By: Claude Opus 5.5 --- bases/rsptx/book_server_api/routers/coach.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/bases/rsptx/book_server_api/routers/coach.py b/bases/rsptx/book_server_api/routers/coach.py index 3c89efe53..51faa7294 100644 --- a/bases/rsptx/book_server_api/routers/coach.py +++ b/bases/rsptx/book_server_api/routers/coach.py @@ -190,23 +190,27 @@ def _escape_parsons_block_markup(block_markup): return html.escape(block_markup, quote=False) +# Activecode source markers, each on a line of its own. The invisible (test) +# suffix is four or more "=" -- activecode.js accepts "=====" and longer from +# older books, so CodeTailor has to as well -- and "===!" a visible suffix. +_INVISIBLE_SUFFIX_RE = re.compile(r"^[ \t]*={4,}[ \t]*$", flags=re.MULTILINE) +_VISIBLE_SUFFIX_RE = re.compile(r"^[ \t]*===![ \t]*$", flags=re.MULTILINE) + + def _extract_suffix_code_from_htmlsrc(htmlsrc): """ Book-authored activecode questions never get question_json.suffix_code populated by the build pipeline (only instructor-authored custom exercises do) -- their test code instead lives in the raw ", htmlsrc, flags=re.DOTALL) text = html.unescape(match.group(1) if match else htmlsrc) - if "====" in text: - return text.partition("====")[2].strip() - if "===!" in text: - return text.partition("===!")[2].strip() - return "" + marker = _INVISIBLE_SUFFIX_RE.search(text) or _VISIBLE_SUFFIX_RE.search(text) + return text[marker.end() :].strip() if marker else "" def _build_static_parsons_response( From 344f6b6cf43fb986e16235161c355b657b873e34 Mon Sep 17 00:00:00 2001 From: Aadarsh Padiyath Date: Thu, 8 Oct 2026 13:36:07 -0400 Subject: [PATCH 2/3] Give CodeTailor help built from subgoal labels Instructors can now write subgoal labels -- full-line comments naming each step -- into a question's backup Parsons problem, and students can write their own in the editor. - A student who asks for help before writing any code gets a puzzle of just the backup problem's labels, under the function's def line from the starter code (locked in place for the settled-block personalization). Untouched starter code counts as no code, so the print call a question ships with no longer sends the student to the LLM. Logged as solution type "subgoal_labels". - When a student's code contains labels, the LLM is asked to follow that plan and keep them, and they are restored into the personalized solution and puzzle, each above the code it names. Labels are copied onto paired distractors too, so they never reveal which block is correct. - Comments never become blocks. Commented-out code ("# total = 0") is not a label, and comments that ship with the starter code ("# write code here") are not the student's labels. - The personalized solution no longer has blank lines where comments were stripped. Co-Authored-By: Claude Opus 5.5 --- bases/rsptx/book_server_api/routers/coach.py | 144 ++++++++++ .../personalized_parsons/end_to_end.py | 32 ++- .../get_personalized_solution.py | 10 + .../personalized_parsons/subgoal_labels.py | 271 ++++++++++++++++++ 4 files changed, 455 insertions(+), 2 deletions(-) create mode 100644 bases/rsptx/book_server_api/routers/personalized_parsons/subgoal_labels.py diff --git a/bases/rsptx/book_server_api/routers/coach.py b/bases/rsptx/book_server_api/routers/coach.py index 51faa7294..618a7eba9 100644 --- a/bases/rsptx/book_server_api/routers/coach.py +++ b/bases/rsptx/book_server_api/routers/coach.py @@ -28,6 +28,15 @@ # ------------------------- from fastapi.responses import JSONResponse from .personalized_parsons.end_to_end import get_parsons_help +from .personalized_parsons.buggy_code_checker import ( + clean_java_code, + clean_python_code, +) +from .personalized_parsons.subgoal_labels import ( + build_label_puzzle, + is_label_line, + label_texts, +) from typing import Optional import re from fastapi import status @@ -233,6 +242,110 @@ def _build_static_parsons_response( ) +def _starter_code_from_question(question): + """ + The code a student sees in the editor before changing anything: a custom + exercise's question_json.starter_code, or for a book-authored question the + visible part of the activecode ", question.htmlsrc or "", flags=re.DOTALL + ) + if not match: + return "" + text = html.unescape(match.group(1)) + suffixes = [ + m.start() + for m in (_INVISIBLE_SUFFIX_RE.search(text), _VISIBLE_SUFFIX_RE.search(text)) + if m + ] + if suffixes: + text = text[: min(suffixes)] + prefix = re.search(r"^[ \t]*\^{4,}[ \t]*\n?", text, flags=re.MULTILINE) + if prefix: + text = text[prefix.end() :] + return re.sub(r"^[ \t]*\^\^\^![ \t]*\n?", "", text, flags=re.MULTILINE) + + +async def _fetch_starter_code(problem_id): + """Starter code for ``problem_id``, or "" if the question can't be found.""" + if not problem_id: + return "" + try: + question = await fetch_question(problem_id) + except Exception as e: + rslogger.error( + f"CodeTailor: could not fetch starter code for '{problem_id}': {e}" + ) + return "" + return _starter_code_from_question(question) if question else "" + + +def _adds_no_code(student_code, starter_code, language): + """ + True when the student has written no code of their own: nothing beyond + def/import lines, comments and blank lines, or exactly the starter code's + statements (e.g. the untouched ``print(average([...]))`` call it ships with). + """ + clean = clean_java_code if language == "java" else clean_python_code + student = clean(student_code) + return not student or student == clean(starter_code) + + +def _drop_starter_comments(student_code, starter_code, language): + """Remove full-line comments the student's code shares with the starter.""" + starter_comments = label_texts(starter_code, language) + return "\n".join( + line + for line in student_code.splitlines() + if not ( + is_label_line(line, language) and " ".join(line.split()) in starter_comments + ) + ) + + +def _function_header(starter_code, language): + """ + The starter code's header lines for the labels-only puzzle: the first + ``def`` and any ``class`` lines above it. Python only -- a Java header + would also need its closing braces -- and None when there is no ``def``. + """ + if language == "java": + return None + header = [] + for line in starter_code.splitlines(): + if re.match(r"\s*class\s", line): + header.append(line.rstrip()) + elif re.match(r"\s*(?:async\s+)?def\s", line): + header.append(line.rstrip()) + return header + return None + + +def _build_label_parsons_response(label_puzzle, parsons_attrs, personalization_level): + """Return the ||split|| response string for a labels-only Parsons problem + built from the backup problem's subgoal labels (see build_label_puzzle).""" + solution, label_markup = label_puzzle + parsons_html = f""" + + """ + return ( + solution + + "||split||" + + parsons_html + + "||split||" + + personalization_level + + "||split||" + + "subgoal_labels" + ) + + @router.get("/get_question_html") async def get_question_html(request: Request, div_id: str): """ @@ -319,6 +432,29 @@ async def parsons_scaffolding( else: parsonsexample_code = "LLM-example" + # A student who asks for help before writing any code has nothing for + # CodeTailor to personalize. When the instructor wrote subgoal labels as + # comments in the backup problem, give them a puzzle of just those labels, + # under the function header from the starter code (locked in place for + # the settled-block "Multiple" personalization). Untouched starter code + # counts as no code. Checked before the login and API-token branches, + # since it needs neither. + if parsons_personalized and parsonsexample_html: + label_language = "java" if (language or "").lower() == "java" else "python" + starter_code = await _fetch_starter_code(problem_id) + if _adds_no_code(student_code or "", starter_code, label_language): + label_puzzle = build_label_puzzle( + parsonsexample_code, + label_language, + header=_function_header(starter_code, label_language), + # settled-block CodeTailor puzzles lock the header in place + settle_header=personalization_level == "Multiple", + ) + if label_puzzle: + return _build_label_parsons_response( + label_puzzle, parsons_attrs, personalization_level + ) + if user is None: # Browsing-mode/anonymous visitors can get the pre-authored backup # Parsons problem, but never the CodeTailor/LLM-personalized one -- @@ -471,6 +607,14 @@ async def parsons_scaffolding( if language and language.lower() == "python": internal_test_case = clean_python_testcase(internal_test_case) + # Comments that ship with the starter code ("# write code here") are not + # the student's subgoal labels, so CodeTailor should not keep them. + student_code = _drop_starter_comments( + student_code or "", + _starter_code_from_question(question), + "java" if (language or "").lower() == "java" else "python", + ) + print("start_to: get_parsons_help", api_token, language, personalization_level) adaptive_attr = 'data-adaptive="true"' diff --git a/bases/rsptx/book_server_api/routers/personalized_parsons/end_to_end.py b/bases/rsptx/book_server_api/routers/personalized_parsons/end_to_end.py index 814defa75..d0aac287f 100644 --- a/bases/rsptx/book_server_api/routers/personalized_parsons/end_to_end.py +++ b/bases/rsptx/book_server_api/routers/personalized_parsons/end_to_end.py @@ -9,6 +9,13 @@ from .get_parsons_code_distractors import generate_code_with_distrator from .get_personalized_solution import get_example_solution, get_fixed_code from .personalize_parsons import compare_code, personalize_Parsons_block +from .subgoal_labels import ( + attach_labels_to_code, + attach_labels_to_puzzle, + has_labels, + label_texts, + strip_comment_lines, +) from .token_compare import code_similarity_score @@ -53,6 +60,12 @@ def get_parsons_help(api_token, language, dict_buggy_code, personalize_level): else: return "Error: invalid personalize_level" + # Full-line comments -- subgoal labels and commented-out code -- stay out + # of block generation, where they would become draggable blocks or + # distractor candidates. When the student wrote labels, they are put back + # into the finished puzzle below. + code_for_blocks = strip_comment_lines(final_fixed_code, language) + if personalize_level == "Solution": # generate Parsons puzzles with personalization only at the solution level with paired distractors final_Parsons_block = generate_multi_personalized_Parsons_blocks( @@ -60,7 +73,7 @@ def get_parsons_help(api_token, language, dict_buggy_code, personalize_level): language, problem_description, buggy_code, - final_fixed_code, + code_for_blocks, default_start_code, default_test_code, unittest_code, @@ -72,7 +85,7 @@ def get_parsons_help(api_token, language, dict_buggy_code, personalize_level): language, problem_description, buggy_code, - final_fixed_code, + code_for_blocks, default_start_code, default_test_code, unittest_code, @@ -80,6 +93,11 @@ def get_parsons_help(api_token, language, dict_buggy_code, personalize_level): else: return "Error: invalid personalize_level" + if final_Parsons_block != "Correct_Code" and has_labels(buggy_code, language): + final_Parsons_block = attach_labels_to_puzzle( + final_Parsons_block, final_fixed_code, language + ) + return ( final_fixed_code, final_Parsons_block, @@ -163,6 +181,16 @@ def request_fixed_code_from_openai( # For other cases, return the more similar one as the personalized result if similarity_personalized >= similarity_most_common: + # Testing stripped the comments; restore the student's labels from + # the LLM's raw output (it was asked to keep them) and drop the + # blank lines the stripped comments left. Only the student's own + # labels are kept, not comments the LLM added. + cleaned_fixed_code = attach_labels_to_code( + cleaned_fixed_code, + fixed_code, + language, + keep=label_texts(buggy_code, language), + ) return cleaned_fixed_code.lstrip(), "AI_personalized" else: return example_solution.lstrip(), "example_more_personalized" diff --git a/bases/rsptx/book_server_api/routers/personalized_parsons/get_personalized_solution.py b/bases/rsptx/book_server_api/routers/personalized_parsons/get_personalized_solution.py index 2048bab17..f5468bde8 100644 --- a/bases/rsptx/book_server_api/routers/personalized_parsons/get_personalized_solution.py +++ b/bases/rsptx/book_server_api/routers/personalized_parsons/get_personalized_solution.py @@ -2,6 +2,7 @@ import re from .evaluate_fixed_code import clean_student_code, unittest_evaluation +from .subgoal_labels import has_labels # Below is the system message as part of the prompt to generate the fixed code system_message = """ @@ -24,6 +25,13 @@ [end-control-structures] """ +# Appended to the system message when the student's code contains subgoal +# labels (full-line comments naming the steps of their plan) +label_message = """ +The [user-code] contains comment lines that name the steps of the student's plan (subgoal labels). +The [fixed-code] should follow that plan. Keep every one of these comment lines in [fixed-code] exactly as written, each one directly above the code that carries out that step and at that code's indentation. Do not add any other comments. +""" + def find_control_structures_java(buggy_code): """ @@ -119,6 +127,8 @@ def build_code_prompt( unittest_code=unittest_code, control_structures=control_structures, ) + if has_labels(buggy_code, language): + system_message += label_message prompt_code = "[user-code]:\n" + buggy_code + "\n[end-user-code]" prompt_messages = [ diff --git a/bases/rsptx/book_server_api/routers/personalized_parsons/subgoal_labels.py b/bases/rsptx/book_server_api/routers/personalized_parsons/subgoal_labels.py new file mode 100644 index 000000000..6c01a7ffe --- /dev/null +++ b/bases/rsptx/book_server_api/routers/personalized_parsons/subgoal_labels.py @@ -0,0 +1,271 @@ +""" +Subgoal labels in CodeTailor puzzles. + +A subgoal label is a full-line comment naming the step that the code below it +carries out, e.g. ``# Count how often each word appears``. Instructors write +them into a question's backup Parsons puzzle, and students may write their own +in the editor. This module + +- builds a labels-only Parsons puzzle from a backup puzzle, for students who + ask for help before writing any code, and +- puts labels back into code and puzzles that CodeTailor built from + comment-free code (the pipeline strips comments before testing and diffing). + +Labels are anchored to the first code line that follows them, and matched by +that line's code with all whitespace removed, so they survive the +re-indentation and whitespace squeezing that block generation applies. +""" + +import ast +import html +import re + +# Parsons markup tags that ride on the end of a code line ("x = 1 #settled", +# "y = 2 #paired: why this is wrong"). They are markup, not labels. +_TAG_RE = re.compile(r"\s*#(?:settled|paired|distractor|matched-fixed|tag:).*$") +_DISTRACTOR_TAGS = ("#paired", "#distractor") + + +_PY_KEYWORD_START_RE = re.compile( + r"(?:return|for|while|if|def|class|import|from|print|with|raise|del|" + r"assert|global|nonlocal|yield|lambda|break|continue|pass)\b" +) +_PY_CLAUSE_RE = re.compile(r"(?:else|try|finally|(?:elif|except)\b.*)\s*:$") +_CODE_PUNCTUATION_RE = re.compile(r"[=()\[\]{}.+\-*/%<>]") + + +def _comment_prefix(language): + return "//" if language == "java" else "#" + + +def _is_full_line_comment(line, language): + stripped = line.strip() + return ( + stripped.startswith(_comment_prefix(language)) + and _TAG_RE.match(stripped) is None + ) + + +def _looks_like_code(text, language): + """ + Whether a comment's text is commented-out code (``total = 0``, + ``for num in nums:``, ``return avg``) rather than a description of a step. + Plain words do not count, even when they happen to parse ("Setup"). + """ + if language == "java": + return bool(re.search(r"[;{}]$", text)) or text in ("else", "} else {") + if _PY_CLAUSE_RE.fullmatch(text): + return True + for candidate in (text, text + "\n pass"): + try: + ast.parse(candidate) + break + except (SyntaxError, ValueError): + continue + else: + return False + return bool(_PY_KEYWORD_START_RE.match(text) or _CODE_PUNCTUATION_RE.search(text)) + + +def is_label_line(line, language): + """True for a full-line comment that describes a step: not a Parsons + markup tag, and not commented-out code.""" + if not _is_full_line_comment(line, language): + return False + text = line.strip()[len(_comment_prefix(language)) :].strip() + return bool(text) and not _looks_like_code(text, language) + + +def has_labels(code, language): + return any(is_label_line(line, language) for line in code.splitlines()) + + +def strip_comment_lines(code, language): + """Drop full-line comments -- labels and commented-out code alike -- so + none becomes a block, leaving code and inline comments untouched.""" + return "\n".join( + line for line in code.splitlines() if not _is_full_line_comment(line, language) + ) + + +def label_texts(code, language): + """The labels in ``code``, whitespace-normalized, for comparing label sets.""" + return { + " ".join(line.split()) + for line in code.splitlines() + if is_label_line(line, language) + } + + +def _code_key(line, language): + """A code line reduced to what survives CodeTailor's cleaning and block + generation: tags and inline comments removed, then all whitespace.""" + line = _TAG_RE.sub("", line) + line = line.split(_comment_prefix(language), 1)[0] + return "".join(line.split()) + + +def label_anchors(code, language, keep=None): + """ + Pair each run of label lines with the code line that follows it. + + Returns a list of ``(labels, key)`` where ``labels`` are the stripped label + lines and ``key`` is the ``_code_key`` of the next code line. Labels with no + code after them have nothing to attach to and are dropped. When ``keep`` is + given, only labels whose normalized text is in it are kept. + """ + anchors, pending = [], [] + for line in code.splitlines(): + if is_label_line(line, language): + label = line.strip() + if keep is None or " ".join(label.split()) in keep: + pending.append(label) + elif _is_full_line_comment(line, language): + continue # commented-out code is not where a label belongs + elif line.strip(): + if pending: + anchors.append((pending, _code_key(line, language))) + pending = [] + return anchors + + +def _split_blocks(markup): + """Split ``---``-separated Parsons markup into blocks of lines.""" + blocks, current = [], [] + for line in markup.splitlines(): + if line.strip() == "---": + blocks.append(current) + current = [] + elif line.strip(): + current.append(line) + blocks.append(current) + return [block for block in blocks if block] + + +def _join_blocks(blocks): + return "\n---\n".join("\n".join(block) for block in blocks) + "\n" + + +def _is_distractor_block(block): + return any(tag in line for line in block for tag in _DISTRACTOR_TAGS) + + +def _indent_of(line): + return line[: len(line) - len(line.lstrip())] + + +def _attach(blocks, anchors, language): + """ + Insert each anchor's labels above its code line, searching forward from the + previous match so repeated lines (two ``return`` statements) keep their + order. Distractor blocks are never anchors. Every label that lands in a + block is also copied into the paired distractor blocks that follow it -- + to the top for a label on the block's first line, otherwise above the + distractor's matching line -- so the labels do not reveal which block of + the pair is correct. + """ + positions = [ + (b, i) + for b, block in enumerate(blocks) + if not _is_distractor_block(block) + for i in range(len(block)) + ] + insertions = [] # (block, line, labels, key) + cursor = 0 + for labels, key in anchors: + for p in range(cursor, len(positions)): + b, i = positions[p] + if _code_key(blocks[b][i], language) == key: + insertions.append((b, i, labels, key)) + cursor = p + 1 + break + + blocks = [list(block) for block in blocks] + # Last insertion first, so earlier line numbers stay valid + for b, i, labels, key in reversed(insertions): + nxt = b + 1 + while nxt < len(blocks) and any("#paired" in ln for ln in blocks[nxt]): + _insert_labels( + blocks[nxt], _paired_line(blocks[nxt], i, key, language), labels + ) + nxt += 1 + _insert_labels(blocks[b], i, labels) + return blocks + + +def _paired_line(block, i, key, language): + """Where a label at line ``i`` of a correct block goes in its distractor.""" + if i == 0: + return 0 + for j, line in enumerate(block): + if _code_key(line, language) == key: + return j + return min(i, len(block)) + + +def _insert_labels(block, i, labels): + reference = block[i] if i < len(block) else block[-1] + indent = _indent_of(reference) + block[i:i] = [indent + label for label in labels] + + +def attach_labels_to_code(code, labeled_code, language, keep=None): + """ + Put the labels from ``labeled_code`` into comment-free ``code``. CodeTailor + drops blank lines before it strips comments, so each removed comment leaves + a whitespace-only line behind; those are dropped too. + """ + lines = [line for line in code.splitlines() if line.strip()] + anchors = label_anchors(labeled_code, language, keep) + if anchors: + lines = _attach([lines], anchors, language)[0] + return "\n".join(lines) + + +def attach_labels_to_puzzle(puzzle, labeled_code, language, keep=None): + """Put the labels from ``labeled_code`` into ``---``-separated puzzle markup + that was generated from the same code with its labels stripped.""" + anchors = label_anchors(labeled_code, language, keep) + if not anchors: + return puzzle + return _join_blocks(_attach(_split_blocks(puzzle), anchors, language)) + + +def build_label_puzzle(parsons_markup, language, header=None, settle_header=False): + """ + Build a labels-only Parsons puzzle from a backup puzzle's raw markup. + + Every non-distractor block that contains labels becomes one block of just + those labels, keeping the instructor's grouping and their relative + indentation. Code lines and distractors are dropped. ``header`` -- the + function's ``def`` line(s) from the starter code -- becomes the first + block, with the labels indented one level under it; without a header the + shallowest label sits at column 0. ``settle_header`` locks the header in + place (``#settled``), as CodeTailor's settled-block puzzles do. + + Returns ``(solution, markup)`` -- the puzzle as plain text (for the copy + button) and the puzzle markup, still HTML-escaped like the input -- or + ``None`` when the puzzle has fewer than two labeled blocks, since one block + is not a puzzle. + """ + label_blocks = [ + [line.rstrip() for line in block if is_label_line(line, language)] + for block in _split_blocks(parsons_markup) + if not _is_distractor_block(block) + ] + label_blocks = [block for block in label_blocks if block] + if len(label_blocks) < 2: + return None + + blocks, base = [], "" + if header: + # the header comes from plain-text starter code; the labels are + # already escaped markup + blocks.append([html.escape(line.rstrip(), quote=False) for line in header]) + base = _indent_of(header[-1]) + " " + cut = min(len(_indent_of(line)) for block in label_blocks for line in block) + blocks += [[base + line[cut:] for line in block] for block in label_blocks] + solution = html.unescape("\n".join(line for b in blocks for line in b)) + if header and settle_header: + blocks[0][-1] += " #settled" + return solution, _join_blocks(blocks) From 260cb8ea39fd4e7c68ec3cd5b44fbde0b4c14f1a Mon Sep 17 00:00:00 2001 From: Aadarsh Padiyath Date: Thu, 8 Oct 2026 14:06:34 -0400 Subject: [PATCH 3/3] Match activecode's source markers and tighten subgoal-label detection Review follow-ups: - Find the test suffix the way activecode.js does: the first run of four or more "=", wherever it is, not only on a line of its own. - Keep a "===!" visible suffix in the starter code. activecode drops only the marker, so cutting the starter there made an untouched exercise look edited and skip the labels-only puzzle. - Look up the starter code in the course's base course first, then fall back to a global name match, as the main CodeTailor path does. Question names are unique only within a base course. - Treat any commented-out Python statement other than a bare expression as code, e.g. "# count: int". Co-Authored-By: Claude Opus 5.5 --- bases/rsptx/book_server_api/routers/coach.py | 79 +++++++++++++------ .../personalized_parsons/subgoal_labels.py | 11 ++- 2 files changed, 64 insertions(+), 26 deletions(-) diff --git a/bases/rsptx/book_server_api/routers/coach.py b/bases/rsptx/book_server_api/routers/coach.py index 618a7eba9..0848a2f0e 100644 --- a/bases/rsptx/book_server_api/routers/coach.py +++ b/bases/rsptx/book_server_api/routers/coach.py @@ -199,11 +199,26 @@ def _escape_parsons_block_markup(block_markup): return html.escape(block_markup, quote=False) -# Activecode source markers, each on a line of its own. The invisible (test) -# suffix is four or more "=" -- activecode.js accepts "=====" and longer from -# older books, so CodeTailor has to as well -- and "===!" a visible suffix. -_INVISIBLE_SUFFIX_RE = re.compile(r"^[ \t]*={4,}[ \t]*$", flags=re.MULTILINE) -_VISIBLE_SUFFIX_RE = re.compile(r"^[ \t]*===![ \t]*$", flags=re.MULTILINE) +# Activecode source markers, matched the way activecode.js matches them: the +# first occurrence anywhere, newline or not. The invisible (test) suffix is +# four or more "=" -- activecode.js accepts "=====" and longer from older +# books -- and the invisible prefix four or more "^". "===!" and "^^^!" mark +# visible suffixes and prefixes, which stay in the editor. +_INVISIBLE_SUFFIX_RE = re.compile(r"={4,}") +_INVISIBLE_PREFIX_RE = re.compile(r"\^{4,}") +_VISIBLE_SUFFIX = "===!" +_VISIBLE_PREFIX = "^^^!" + + +def _drop_marker(text, marker): + """Remove the first ``marker`` and the newline right after it, if any.""" + start = text.find(marker) + if start == -1: + return text + end = start + len(marker) + if text[end : end + 1] == "\n": + end += 1 + return text[:start] + text[end:] def _extract_suffix_code_from_htmlsrc(htmlsrc): @@ -218,8 +233,12 @@ def _extract_suffix_code_from_htmlsrc(htmlsrc): return "" match = re.search(r"]*>(.*?)", htmlsrc, flags=re.DOTALL) text = html.unescape(match.group(1) if match else htmlsrc) - marker = _INVISIBLE_SUFFIX_RE.search(text) or _VISIBLE_SUFFIX_RE.search(text) - return text[marker.end() :].strip() if marker else "" + marker = _INVISIBLE_SUFFIX_RE.search(text) + if marker: + return text[marker.end() :].strip() + if _VISIBLE_SUFFIX in text: + return text.partition(_VISIBLE_SUFFIX)[2].strip() + return "" def _build_static_parsons_response( @@ -246,8 +265,9 @@ def _starter_code_from_question(question): """ The code a student sees in the editor before changing anything: a custom exercise's question_json.starter_code, or for a book-authored question the - visible part of the activecode