From e0adacf2b8264469b939daa114293da65a6e7782 Mon Sep 17 00:00:00 2001 From: Aadarsh Padiyath Date: Tue, 25 Aug 2026 21:00:12 -0500 Subject: [PATCH 1/4] Fix CodeTailor raw Parsons markup leaking into LLM pipeline and clipboard The "Example" field fed into the personalized-Parsons LLM pipeline was the raw pp-authoring markup ("---"-delimited blocks with #distractor/#paired tags), not compilable code. That raw text is used verbatim as the LLM prompt's [sample-solution] and, on generation/validation failure, is returned directly as the "fixed" solution -- which then gets copied to the clipboard by the codetailor "copy to clipboard" feature. Also fix aggregate_code_to_full_Parsons_block, which merged any run of same-indentation-level lines into one block regardless of whether they were actually nested under something. This silently collapsed unrelated sibling statements (even across separate top-level statements) into a single undraggable block, most visibly for flat/sequential code where the entire example became one block. It was previously masked because the leaked raw markup's own "---" lines were incidentally reinterpreted by the frontend as block boundaries. Co-Authored-By: Claude Sonnet 5 --- bases/rsptx/book_server_api/routers/coach.py | 8 +++++++- .../generate_parsons_blocks.py | 18 +++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/bases/rsptx/book_server_api/routers/coach.py b/bases/rsptx/book_server_api/routers/coach.py index 8abc939e2..69eb4f5e2 100644 --- a/bases/rsptx/book_server_api/routers/coach.py +++ b/bases/rsptx/book_server_api/routers/coach.py @@ -466,7 +466,13 @@ def parsons_help( "Problem Name": problem_id, "Problem Description": problem_description, "Unittest_Code": internal_test_case, - "Example": parsonsexample_code, # This is the html of the example Parsons problem + "Example": ( + parsonsexample_code + if parsonsexample_code == "LLM-example" + else extract_parsons_solution(parsonsexample_code) + ), # compilable solution code, not raw Parsons block markup -- + # this can be returned verbatim as the fixed/example code when + # LLM personalization falls back (see end_to_end.generate_example_solution) "CF (Code)": student_code, } return get_parsons_help(api_token, language, input_dict, personalization_level) diff --git a/bases/rsptx/book_server_api/routers/personalized_parsons/generate_parsons_blocks.py b/bases/rsptx/book_server_api/routers/personalized_parsons/generate_parsons_blocks.py index 7ad666e50..1776f3e6a 100644 --- a/bases/rsptx/book_server_api/routers/personalized_parsons/generate_parsons_blocks.py +++ b/bases/rsptx/book_server_api/routers/personalized_parsons/generate_parsons_blocks.py @@ -492,15 +492,20 @@ def aggregate_code_to_Parsons_block_with_distractor(blocks): def aggregate_code_to_full_Parsons_block(blocks): """ - Aggregate the code into full Parsons blocks. All code lines with the same indentation level are grouped together. + Aggregate the code into full Parsons blocks. Lines nested inside something + (indented deeper than the snippet's root level) are grouped together as a + single block per contiguous run, since only their placement under the + enclosing header matters. Lines at the root level always get their own + block, since each one is an independent, reorderable statement. 1. Function definitions (def) and return statements are treated as separate blocks. 2. Import statements are grouped together into a single block. - 3. Other lines with the same indentation level are grouped together. - 4. If the indentation level changes, a new block is started. + 3. Root-level lines are never merged with each other. + 4. Nested lines at the same indentation level are grouped together, until the indentation level changes. 5. Blank lines are preserved within blocks. 6. Each block ends with a newline character. """ - current_indent = check_indentation_level(blocks[0]) + base_indent = check_indentation_level(blocks[0]) + current_indent = base_indent all_Parsons_blocks = [] Parsons_block = "" import_block = "" @@ -529,7 +534,10 @@ def aggregate_code_to_full_Parsons_block(blocks): block ) # add the def or return statement as its own block current_indent = this_indent - elif this_indent == current_indent: + elif this_indent == current_indent and this_indent != base_indent: + # Only merge lines that are nested inside something (indented deeper + # than the snippet's root level) -- sibling statements at the root + # level each get their own block, same as def/return already do. Parsons_block += block else: if Parsons_block: # append current block before resetting From 317783ae64a5f8eb77bb920ae74004178be6cbcb Mon Sep 17 00:00:00 2001 From: Aadarsh Padiyath Date: Wed, 26 Aug 2026 10:00:34 -0500 Subject: [PATCH 2/4] Fix def/return and import detection in Parsons block aggregation startswith(("def", "return")) matched any line beginning with those substrings, not just the keywords -- e.g. "default_value = 5" or "returned_value = x" were misclassified as def/return statements, splitting/merging blocks incorrectly. Use a word-boundary regex instead. Similarly, the import-grouping check only matched "import ...", not "from ... import ...", so from-imports were split into separate blocks despite the docstring's claim that all imports are grouped together. Co-Authored-By: Claude Sonnet 5 --- .../personalized_parsons/generate_parsons_blocks.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bases/rsptx/book_server_api/routers/personalized_parsons/generate_parsons_blocks.py b/bases/rsptx/book_server_api/routers/personalized_parsons/generate_parsons_blocks.py index 1776f3e6a..dccb73e32 100644 --- a/bases/rsptx/book_server_api/routers/personalized_parsons/generate_parsons_blocks.py +++ b/bases/rsptx/book_server_api/routers/personalized_parsons/generate_parsons_blocks.py @@ -515,18 +515,20 @@ def aggregate_code_to_full_Parsons_block(blocks): block += "\n" this_indent = check_indentation_level(block) + stripped_block = block.strip() + is_import_line = bool(re.match(r"^(import|from)\b", stripped_block)) - if block.strip().startswith("import"): + if is_import_line: import_block += block # add to the import block continue # continue processing without disrupting other logic - if import_block and not block.strip().startswith("import"): + if import_block and not is_import_line: all_Parsons_blocks.append( import_block ) # add the collected import block at the beginning import_block = "" # reset the import block - if block.strip().startswith(("def", "return")): + if re.match(r"^(def|return)\b", stripped_block): if Parsons_block: # append any current accumulated block all_Parsons_blocks.append(Parsons_block) Parsons_block = "" # reset Parsons block From 6b34e46674aaa87d5e85c3c710122d14c36d7771 Mon Sep 17 00:00:00 2001 From: Aadarsh Padiyath Date: Wed, 26 Aug 2026 17:47:37 -0500 Subject: [PATCH 3/4] Fix asymmetric Parsons block indentation from leading trim() fulltext.trim() stripped real leading indentation off only the first block's first line whenever the browser's
-tag newline-eating
quirk left that indentation at the very start of the string, while
every later block (after its own "---") kept its indent untouched.
This showed up as a spurious extra indent level on personalized
CodeTailor Parsons puzzles. Blank leading/trailing lines within a
block are already discarded further down per block, so trimEnd() is
enough to clean up the template's trailing whitespace without eating
real code indentation.

Co-Authored-By: Claude Sonnet 5 
---
 bases/rsptx/interactives/runestone/parsons/js/parsons.js | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/bases/rsptx/interactives/runestone/parsons/js/parsons.js b/bases/rsptx/interactives/runestone/parsons/js/parsons.js
index d1e87a320..bb65fe801 100644
--- a/bases/rsptx/interactives/runestone/parsons/js/parsons.js
+++ b/bases/rsptx/interactives/runestone/parsons/js/parsons.js
@@ -98,7 +98,13 @@ export default class Parsons extends RunestoneBase {
         this.checkCount = 0;
         this.numDistinct = 0;
         this.hasSolved = false;
-        this.initializeLines(fulltext.trim());
+        // Only trim trailing whitespace here. A leading trim() would strip real
+        // leading indentation off the first line of the first block whenever the
+        // 
 tag's own leading newline has already been consumed by the browser's
+        // HTML parser (a well-known 
 quirk), silently de-indenting only the
+        // first block relative to every other block. Blank leading/trailing lines
+        // within a block are still discarded further down, per block.
+        this.initializeLines(fulltext.trimEnd());
         this.initializeView();
         this.caption = "Parsons";
         this.addCaption("runestone");

From 313e8c97a0502badeb983b51e02df2368c002e3a Mon Sep 17 00:00:00 2001
From: Aadarsh Padiyath 
Date: Wed, 26 Aug 2026 18:12:49 -0500
Subject: [PATCH 4/4] Fix HTML entities leaking into CodeTailor clipboard copy

extract_parsons_code() deliberately leaves HTML entities like "<"
escaped, since its result also gets re-embedded into a 
 block for
the Parsons widget to re-render (a real "<" there would be misread as
an HTML tag). extract_parsons_solution() reused that same escaped text
for the plain-text "copy answer to clipboard" solution and code_answer
without ever unescaping it, so any source line containing an escaped
entity (e.g. "lst[index] % 2 != 0" preceded by "index < len(lst)")
copied to the clipboard with the literal entity instead of "<", making
the copied code fail to compile.

Co-Authored-By: Claude Sonnet 5 
---
 bases/rsptx/book_server_api/routers/coach.py | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/bases/rsptx/book_server_api/routers/coach.py b/bases/rsptx/book_server_api/routers/coach.py
index 69eb4f5e2..e056790e5 100644
--- a/bases/rsptx/book_server_api/routers/coach.py
+++ b/bases/rsptx/book_server_api/routers/coach.py
@@ -148,6 +148,12 @@ def extract_parsons_solution(parsonsexample_code):
     correct solution), and scaffolding tags ("#settled", "#tag:...;...;") are
     stripped from the remaining blocks. Mirrors the block-parsing logic in
     runestone/parsons/js/parsons.js::initializeLines.
+
+    extract_parsons_code() deliberately leaves HTML entities (e.g. "<")
+    escaped, since its result is also re-embedded into a 
 block for the
+    Parsons widget to re-render. This function's result instead goes straight
+    into a plain-text answer (clipboard copy, code_answer), so it must be
+    unescaped here or a "<" in the source shows up as literal "<".
     """
     blocks = parsonsexample_code.split("---")
     clean_lines = []
@@ -160,7 +166,7 @@ def extract_parsons_solution(parsonsexample_code):
             line = re.sub(r"#tag:[^;]*;[^;]*;\s*$", "", line)
             line = line.rstrip()
             if line.strip() and line.strip() != "=====":
-                clean_lines.append(line)
+                clean_lines.append(html.unescape(line))
     return "\n".join(clean_lines)