diff --git a/bases/rsptx/book_server_api/routers/coach.py b/bases/rsptx/book_server_api/routers/coach.py index 8abc939e2..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)
 
 
@@ -466,7 +472,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..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
@@ -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 = ""
@@ -510,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
@@ -529,7 +536,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
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");