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");