Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions bases/rsptx/book_server_api/routers/coach.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
from rsptx.db.crud.crud import fetch_api_token
from rsptx.db.crud.course import fetch_course
from rsptx.db.crud.question import fetch_question
from rsptx.db.crud import fetch_source_code

# .. _APIRouter config:
#
Expand Down Expand Up @@ -241,6 +242,79 @@ def _extract_suffix_code_from_htmlsrc(htmlsrc):
return ""


# A quoted name with a file extension, e.g. open("scores.csv") or
# new File("words.txt") -- a possible data file to look up in source_code.
# Single spaces inside the name are allowed, since uploaded datafiles keep
# theirs (open("my data.csv")).
_FILENAME_LITERAL_RE = re.compile(
r"""["']([\w\-./]+(?: [\w\-./]+)*\.[A-Za-z0-9]{1,8})["']"""
)
# Bounds the source_code lookups that guessed filename literals can trigger.
# Names the question declares in data-datafile are always kept.
_MAX_DATA_FILE_CANDIDATES = 20


def _data_file_candidates(htmlsrc, *code_sources):
"""
Filenames the question's program may open. Livecode (JOBE) questions list
them in data-datafile, but Python questions don't declare them anywhere --
Skulpt resolves open() at runtime -- so also take every quoted
filename-like literal from the question's code, its tests and the
student's code. Names that turn out not to be data files simply miss in
the source_code lookup.
"""
declared = []
htmlsrc = htmlsrc or ""
match = re.search(r'data-datafile\s*=\s*"([^"]*)"', htmlsrc)
if match:
declared.extend(name.strip() for name in match.group(1).split(","))
declared = list(dict.fromkeys(name for name in declared if name))
textarea = re.search(r"<textarea[^>]*>(.*?)</textarea>", htmlsrc, flags=re.DOTALL)
question_code = html.unescape(textarea.group(1)) if textarea else ""
inferred = []
for text in (question_code, *code_sources):
inferred.extend(_FILENAME_LITERAL_RE.findall(text or ""))
inferred = [name for name in dict.fromkeys(inferred) if name not in declared]
return declared + inferred[:_MAX_DATA_FILE_CANDIDATES]


async def _fetch_data_files(candidates, course_scopes):
"""
Resolve candidate filenames against source_code -- the same store
Skulpt's file reader and livecode.js fall back to -- so the files can be
shipped to JOBE with every CodeTailor test run.

course_scopes is a list of (base_course, course_name) pairs tried in
order: the requesting course first, then the book the question came from
(which differs for cloned courses and cross-book selectquestions).
Returns dicts in the shape evaluate_fixed_code._push_data_files expects.
"""
data_files = []
for name in candidates:
for base_course, course_name in course_scopes:
try:
row = await fetch_source_code(
base_course=base_course, course_name=course_name, filename=name
)
except Exception:
# fetch_source_code raises when nothing matches
row = None
if row and row.main_code is not None:
filename = row.filename or name
# data-datafile names the acid while the code names the file,
# so the same file can be resolved twice -- ship it once
if filename not in {data_file["filename"] for data_file in data_files}:
data_files.append(
{
"filename": filename,
"content": row.main_code,
"is_binary": bool(row.is_binary),
}
)
break
return data_files


def _build_static_parsons_response(
parsonsexample_code, parsons_attrs, personalization_level
):
Expand Down Expand Up @@ -640,6 +714,25 @@ async def parsons_scaffolding(
if language and language.lower() == "python":
internal_test_case = clean_python_testcase(internal_test_case)

# Files the program opens (datafiles) must travel with each JOBE test run,
# or every generated solution fails with a missing-file error.
data_files = await _fetch_data_files(
_data_file_candidates(question.htmlsrc, internal_test_case, student_code),
list(
dict.fromkeys(
[
(basecourse, course_name),
(question.base_course, question.base_course),
]
)
),
)
if data_files:
rslogger.info(
f"CodeTailor: '{problem_id}' uses data files "
f"{[data_file['filename'] for data_file in data_files]}"
)

# 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(
Expand Down Expand Up @@ -693,6 +786,7 @@ def parsons_help(
# 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,
"Data_Files": data_files,
}
return get_parsons_help(api_token, language, input_dict, personalization_level)

Expand All @@ -717,6 +811,7 @@ def parsons_help(
language,
problem_description,
internal_test_case,
data_files,
)
if not example_code:
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def get_parsons_help(api_token, language, dict_buggy_code, personalize_level):
Args:
api_token: The API token for GenerativeAI -- now only support OpenAI
language: The programming language of the code
dict_buggy_code: A dictionary containing the current buggy code, problem description, unittest code, and a predefined example
dict_buggy_code: A dictionary containing the current buggy code, problem description, unittest code, a predefined example, and (optionally) the question's data files
personalize_level: The level of personalization to apply (e.g., "Solution", "Multiple")
Returns:
A tuple containing the final fixed code, the final Parsons block, the solution generation type, and the generation result type.
Expand All @@ -39,6 +39,8 @@ def get_parsons_help(api_token, language, dict_buggy_code, personalize_level):
unittest_code = dict_buggy_code["Unittest_Code"].replace("\\n", "\n")
# This can be "LLM-example" or a predefined example code
predefined_example = dict_buggy_code["Example"].replace("\\n", "\n")
# files the program opens (e.g. datafiles) -- shipped with every test run
data_files = dict_buggy_code.get("Data_Files")
# check if language is java, keep it as it is, otherwise, assign it to "python" as default
if language.lower() == "java" or language.lower() == "python":
language = language.lower()
Expand All @@ -55,6 +57,7 @@ def get_parsons_help(api_token, language, dict_buggy_code, personalize_level):
default_test_code,
predefined_example,
unittest_code,
data_files=data_files,
)
final_fixed_code = cleaned_fixed_code.lstrip()
else:
Expand All @@ -77,6 +80,7 @@ def get_parsons_help(api_token, language, dict_buggy_code, personalize_level):
default_start_code,
default_test_code,
unittest_code,
data_files=data_files,
)
elif personalize_level == "Multiple":
# generate Parsons puzzles with personalization at both solution and multiple levels with paired distractors and potentially settled block lines
Expand All @@ -89,6 +93,7 @@ def get_parsons_help(api_token, language, dict_buggy_code, personalize_level):
default_start_code,
default_test_code,
unittest_code,
data_files=data_files,
)
else:
return "Error: invalid personalize_level"
Expand Down Expand Up @@ -121,6 +126,7 @@ def request_fixed_code_from_openai(
situation,
failure_reason,
unittest_result,
data_files=None,
):
"""
Request a fixed code from GenerativeAI based on the buggy code and other parameters.
Expand All @@ -139,6 +145,7 @@ def request_fixed_code_from_openai(
situation (str): The situation for the request, e.g., "a correct answer"
failure_reason (str): The reason for the failure, e.g., "not correct"
unittest_result (bool): The result of the unittest evaluation for the buggy code
data_files (list): The question's data files the code may open
Outputs:
cleaned_fixed_code (str): The cleaned fixed code generated by the LLM
generation_result_type (str): The type of generation result, e.g., "AI_personalized", "example_more_personalized", "example_solution"
Expand All @@ -165,6 +172,7 @@ def request_fixed_code_from_openai(
default_start_code,
default_test_code,
unittest_code,
data_files=data_files,
)

print("this-round-result:", unittest_result, cleaned_fixed_code)
Expand Down Expand Up @@ -217,11 +225,17 @@ def request_fixed_code_from_openai(
situation="a correct answer",
failure_reason="not correct",
unittest_result=False,
data_files=data_files,
)


def generate_example_solution(
api_token, language, problem_description, unittest_code, predefined_example
api_token,
language,
problem_description,
unittest_code,
predefined_example,
data_files=None,
):
"""
Generate or retrieve an example solution based on the provided parameters.
Expand All @@ -231,12 +245,13 @@ def generate_example_solution(
problem_description (str): textual question description
unittest_code (str): The unittest code for the question
predefined_example (str): A predefined example solution or the keyword "LLM-example" to generate one using LLM
data_files (list): The question's data files the code may open
Outputs:
example_solution (str): The example solution, either generated by LLM or the predefined one
"""
if predefined_example == "LLM-example":
example_solution = get_example_solution(
api_token, language, problem_description, unittest_code
api_token, language, problem_description, unittest_code, data_files
)
else:
example_solution = predefined_example
Expand All @@ -253,6 +268,7 @@ def generate_personalized_fixed_code(
predefined_example,
unittest_code,
API_attempt=0,
data_files=None,
):
"""
Generate a personalized fixed code based on the buggy code and other parameters.
Expand All @@ -266,6 +282,7 @@ def generate_personalized_fixed_code(
predefined_example (str): A predefined example solution or the keyword "LLM-example" to generate one using LLM
unittest_code (str): The unittest code for the question
API_attempt (int): The current API attempt number -- starts from 0 (reserved in case we want to use different API keys in the future)
data_files (list): The question's data files the code may open
Outputs:
cleaned_fixed_code (str): The cleaned fixed code generated by the LLM
generation_result_type (str): The type of generation result, e.g., "AI_personalized", "example_more_personalized", "example_solution", "written_code_correct", "empty_beginning"
Expand All @@ -279,6 +296,7 @@ def generate_personalized_fixed_code(
default_start_code,
default_test_code,
unittest_case=unittest_code,
data_files=data_files,
)
if unittest_result is not True:
# If the code is not correct, we will TRY to get an example solution first
Expand All @@ -288,6 +306,7 @@ def generate_personalized_fixed_code(
problem_description,
unittest_code,
predefined_example,
data_files=data_files,
)

try:
Expand All @@ -307,6 +326,7 @@ def generate_personalized_fixed_code(
situation="",
failure_reason="",
unittest_result="",
data_files=data_files,
)

return result_personalized
Expand All @@ -320,7 +340,12 @@ def generate_personalized_fixed_code(
# If the code is empty, directly return the example solution
else:
example_solution = generate_example_solution(
api_token, language, problem_description, unittest_code, predefined_example
api_token,
language,
problem_description,
unittest_code,
predefined_example,
data_files=data_files,
)

return example_solution.lstrip(), "example_solution"
Expand All @@ -335,6 +360,7 @@ def generate_multi_personalized_Parsons_blocks(
default_start_code,
default_test_code,
unittest_code,
data_files=None,
):
"""
Generate personalized Parsons blocks based on the student buggy code and fixed code.
Expand All @@ -347,6 +373,7 @@ def generate_multi_personalized_Parsons_blocks(
default_start_code (str): The default starting code for the question, current ""
default_test_code (str): The default test code for the question, current ""
unittest_code (str): The unittest code for the question
data_files (list): The question's data files the code may open
Outputs:
personalized_Parsons_block (str): The generated personalized Parsons block code
"""
Expand Down Expand Up @@ -388,6 +415,7 @@ def generate_multi_personalized_Parsons_blocks(
default_start_code,
default_test_code,
unittest_code,
data_files=data_files,
)
# If the code with distractors passes the unittest, we will remove the distractor from the distractors list
if unittest_flag:
Expand Down
Loading
Loading