diff --git a/bases/rsptx/book_server_api/routers/coach.py b/bases/rsptx/book_server_api/routers/coach.py index 3c89efe53..0848a2f0e 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 @@ -190,22 +199,45 @@ def _escape_parsons_block_markup(block_markup): return html.escape(block_markup, quote=False) +# 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): """ 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() + 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 "" @@ -229,6 +261,124 @@ 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 + activecode ", question.htmlsrc or "", flags=re.DOTALL + ) + if not match: + return "" + text = html.unescape(match.group(1)) + prefix = _INVISIBLE_PREFIX_RE.search(text) + if prefix: + text = _drop_marker(text[prefix.start() :], prefix.group(0)) + text = _drop_marker(text, _VISIBLE_PREFIX) + suffix = _INVISIBLE_SUFFIX_RE.search(text) + if suffix: + text = text[: suffix.start()] + return _drop_marker(text, _VISIBLE_SUFFIX) + + +async def _fetch_starter_code(problem_id, course_name): + """ + Starter code for ``problem_id``, or "" if the question can't be found. + Question names are unique only within a base course, so look in the + course's base course first, then fall back to a global name match (a + cloned course or a question pulled in from another book) -- the same + order the main CodeTailor path uses. + """ + if not problem_id: + return "" + try: + question = None + if course_name: + try: + course = await fetch_course(course_name) + except AttributeError: + course = None + basecourse = getattr(course, "base_course", None) + if basecourse: + question = await fetch_question(problem_id, basecourse=basecourse) + if not question: + 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): """ @@ -315,6 +465,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, course_name) + 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 -- @@ -467,6 +640,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..d0a5de17b --- /dev/null +++ b/bases/rsptx/book_server_api/routers/personalized_parsons/subgoal_labels.py @@ -0,0 +1,276 @@ +""" +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``, ``count: int``) rather than a + description of a step. Any statement other than a bare expression is + code; a bare expression is code only when it starts with a keyword or uses + code punctuation, so 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: + tree = ast.parse(candidate) + break + except (SyntaxError, ValueError): + continue + else: + return False + if any(not isinstance(statement, ast.Expr) for statement in tree.body): + return True + 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)