Run black formatter on all Python files
Format code according to black style guidelines for consistency
This commit is contained in:
parent
408b419b94
commit
22db7a9a8a
13 changed files with 593 additions and 407 deletions
178
activity.py
178
activity.py
|
|
@ -34,7 +34,7 @@ from activity_utils import (
|
|||
resolve_conditional_navigation,
|
||||
select_weighted_random,
|
||||
get_progressive_hint,
|
||||
create_template_context
|
||||
create_template_context,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -103,7 +103,12 @@ def get_activity_content(file_path):
|
|||
|
||||
|
||||
def loop_through_steps_until_question(
|
||||
activity_content, activity_state, room_name, username, classifier_model="MODEL_0", feedback_model="MODEL_0"
|
||||
activity_content,
|
||||
activity_state,
|
||||
room_name,
|
||||
username,
|
||||
classifier_model="MODEL_0",
|
||||
feedback_model="MODEL_0",
|
||||
):
|
||||
room = get_room(room_name)
|
||||
|
||||
|
|
@ -140,19 +145,19 @@ def loop_through_steps_until_question(
|
|||
max_attempts=activity_state.max_attempts,
|
||||
current_section=current_section_id,
|
||||
current_step=current_step_id,
|
||||
username=username
|
||||
username=username,
|
||||
)
|
||||
|
||||
# Filter and render content blocks (supports conditional blocks and templates)
|
||||
filtered_blocks = filter_content_blocks(
|
||||
step["content_blocks"],
|
||||
activity_state.dict_metadata,
|
||||
context
|
||||
step["content_blocks"], activity_state.dict_metadata, context
|
||||
)
|
||||
|
||||
if filtered_blocks:
|
||||
content = "\n\n".join(filtered_blocks)
|
||||
translated_content = translate_text(content, user_language, feedback_model)
|
||||
translated_content = translate_text(
|
||||
content, user_language, feedback_model
|
||||
)
|
||||
new_message = Message(
|
||||
username="System", content=translated_content, room_id=room.id
|
||||
)
|
||||
|
|
@ -179,7 +184,7 @@ def loop_through_steps_until_question(
|
|||
max_attempts=activity_state.max_attempts,
|
||||
current_section=current_section_id,
|
||||
current_step=current_step_id,
|
||||
username=username
|
||||
username=username,
|
||||
)
|
||||
|
||||
# Render template variables in question
|
||||
|
|
@ -270,8 +275,12 @@ def start_activity(room_name, s3_file_path, username):
|
|||
|
||||
# Loop through steps until a question is found or the end is reached
|
||||
loop_through_steps_until_question(
|
||||
activity_content, activity_state, room_name, username,
|
||||
classifier_model=classifier_model, feedback_model=feedback_model
|
||||
activity_content,
|
||||
activity_state,
|
||||
room_name,
|
||||
username,
|
||||
classifier_model=classifier_model,
|
||||
feedback_model=feedback_model,
|
||||
)
|
||||
|
||||
# Emit activity status update
|
||||
|
|
@ -531,7 +540,7 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0"
|
|||
if "metadata_conditions" in transition:
|
||||
conditions_met = check_conditions(
|
||||
activity_state.dict_metadata,
|
||||
transition["metadata_conditions"]
|
||||
transition["metadata_conditions"],
|
||||
)
|
||||
if not conditions_met:
|
||||
# Skip this transition if conditions not met
|
||||
|
|
@ -558,7 +567,9 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0"
|
|||
elif value == "the-llms-response":
|
||||
continue
|
||||
elif isinstance(value, str):
|
||||
if value.startswith("n+random(") and value.endswith(")"):
|
||||
if value.startswith("n+random(") and value.endswith(
|
||||
")"
|
||||
):
|
||||
# Extract the range and apply the random increment
|
||||
range_values = value[9:-1].split(",")
|
||||
if len(range_values) == 2:
|
||||
|
|
@ -568,11 +579,17 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0"
|
|||
) + random.randint(x, y)
|
||||
elif value.startswith("n+") or value.startswith("n-"):
|
||||
# Check if this is string concatenation (n+,value) or numeric operation (n+5)
|
||||
if value.startswith("n+,") or value.startswith("n-,"):
|
||||
if value.startswith("n+,") or value.startswith(
|
||||
"n-,"
|
||||
):
|
||||
# String concatenation: append/remove from existing value
|
||||
operation = value[:2] # "n+" or "n-"
|
||||
suffix = value[3:] # Everything after "n+," or "n-,"
|
||||
existing_value = activity_state.dict_metadata.get(key, "")
|
||||
suffix = value[
|
||||
3:
|
||||
] # Everything after "n+," or "n-,"
|
||||
existing_value = (
|
||||
activity_state.dict_metadata.get(key, "")
|
||||
)
|
||||
if operation == "n+":
|
||||
# Append with comma separator if existing value is non-empty
|
||||
if existing_value:
|
||||
|
|
@ -583,7 +600,9 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0"
|
|||
# Remove suffix from existing value
|
||||
if existing_value:
|
||||
parts = existing_value.split(",")
|
||||
parts = [p for p in parts if p != suffix]
|
||||
parts = [
|
||||
p for p in parts if p != suffix
|
||||
]
|
||||
value = ",".join(parts)
|
||||
else:
|
||||
value = existing_value
|
||||
|
|
@ -592,11 +611,23 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0"
|
|||
try:
|
||||
c = int(value[2:])
|
||||
if value.startswith("n+"):
|
||||
value = activity_state.dict_metadata.get(key, 0) + c
|
||||
value = (
|
||||
activity_state.dict_metadata.get(
|
||||
key, 0
|
||||
)
|
||||
+ c
|
||||
)
|
||||
elif value.startswith("n-"):
|
||||
value = activity_state.dict_metadata.get(key, 0) - c
|
||||
value = (
|
||||
activity_state.dict_metadata.get(
|
||||
key, 0
|
||||
)
|
||||
- c
|
||||
)
|
||||
except ValueError:
|
||||
print(f"Warning: Invalid numeric operation '{value}' for key '{key}'")
|
||||
print(
|
||||
f"Warning: Invalid numeric operation '{value}' for key '{key}'"
|
||||
)
|
||||
new_metadata[key] = value
|
||||
activity_state.add_metadata(key, value)
|
||||
|
||||
|
|
@ -608,7 +639,9 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0"
|
|||
elif value == "the-llms-response":
|
||||
continue
|
||||
elif isinstance(value, str):
|
||||
if value.startswith("n+random(") and value.endswith(")"):
|
||||
if value.startswith("n+random(") and value.endswith(
|
||||
")"
|
||||
):
|
||||
# Extract the range and apply the random increment
|
||||
range_values = value[9:-1].split(",")
|
||||
if len(range_values) == 2:
|
||||
|
|
@ -618,11 +651,17 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0"
|
|||
) + random.randint(x, y)
|
||||
elif value.startswith("n+") or value.startswith("n-"):
|
||||
# Check if this is string concatenation (n+,value) or numeric operation (n+5)
|
||||
if value.startswith("n+,") or value.startswith("n-,"):
|
||||
if value.startswith("n+,") or value.startswith(
|
||||
"n-,"
|
||||
):
|
||||
# String concatenation: append/remove from existing value
|
||||
operation = value[:2] # "n+" or "n-"
|
||||
suffix = value[3:] # Everything after "n+," or "n-,"
|
||||
existing_value = activity_state.dict_metadata.get(key, "")
|
||||
suffix = value[
|
||||
3:
|
||||
] # Everything after "n+," or "n-,"
|
||||
existing_value = (
|
||||
activity_state.dict_metadata.get(key, "")
|
||||
)
|
||||
if operation == "n+":
|
||||
# Append with comma separator if existing value is non-empty
|
||||
if existing_value:
|
||||
|
|
@ -633,7 +672,9 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0"
|
|||
# Remove suffix from existing value
|
||||
if existing_value:
|
||||
parts = existing_value.split(",")
|
||||
parts = [p for p in parts if p != suffix]
|
||||
parts = [
|
||||
p for p in parts if p != suffix
|
||||
]
|
||||
value = ",".join(parts)
|
||||
else:
|
||||
value = existing_value
|
||||
|
|
@ -642,11 +683,23 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0"
|
|||
try:
|
||||
c = int(value[2:])
|
||||
if value.startswith("n+"):
|
||||
value = activity_state.dict_metadata.get(key, 0) + c
|
||||
value = (
|
||||
activity_state.dict_metadata.get(
|
||||
key, 0
|
||||
)
|
||||
+ c
|
||||
)
|
||||
elif value.startswith("n-"):
|
||||
value = activity_state.dict_metadata.get(key, 0) - c
|
||||
value = (
|
||||
activity_state.dict_metadata.get(
|
||||
key, 0
|
||||
)
|
||||
- c
|
||||
)
|
||||
except ValueError:
|
||||
print(f"Warning: Invalid numeric operation '{value}' for key '{key}'")
|
||||
print(
|
||||
f"Warning: Invalid numeric operation '{value}' for key '{key}'"
|
||||
)
|
||||
new_metadata[key] = value
|
||||
metadata_tmp_keys.append(key)
|
||||
activity_state.add_metadata(key, value)
|
||||
|
|
@ -728,21 +781,27 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0"
|
|||
|
||||
# Handle metadata_weighted_random (v2.0)
|
||||
if "metadata_weighted_random" in transition:
|
||||
for key, weighted_options in transition["metadata_weighted_random"].items():
|
||||
for key, weighted_options in transition[
|
||||
"metadata_weighted_random"
|
||||
].items():
|
||||
selected_value = select_weighted_random(weighted_options)
|
||||
new_metadata[key] = selected_value
|
||||
activity_state.add_metadata(key, selected_value)
|
||||
|
||||
# Handle metadata_tmp_weighted_random (v2.0)
|
||||
if "metadata_tmp_weighted_random" in transition:
|
||||
for key, weighted_options in transition["metadata_tmp_weighted_random"].items():
|
||||
for key, weighted_options in transition[
|
||||
"metadata_tmp_weighted_random"
|
||||
].items():
|
||||
selected_value = select_weighted_random(weighted_options)
|
||||
new_metadata[key] = selected_value
|
||||
metadata_tmp_keys.append(key)
|
||||
activity_state.add_metadata(key, selected_value)
|
||||
|
||||
# Execute the post-script if it exists (supports both old and new naming)
|
||||
post_script = step.get("post_script") or step.get("processing_script")
|
||||
post_script = step.get("post_script") or step.get(
|
||||
"processing_script"
|
||||
)
|
||||
if post_script and (
|
||||
transition.get("run_post_script", False)
|
||||
or transition.get("run_processing_script", False)
|
||||
|
|
@ -767,7 +826,9 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0"
|
|||
|
||||
# Check if processing script wants to override the transition
|
||||
if "next_section_and_step" in result:
|
||||
final_next_section_and_step = result["next_section_and_step"]
|
||||
final_next_section_and_step = result[
|
||||
"next_section_and_step"
|
||||
]
|
||||
print(
|
||||
f"DEBUG: Processing script overriding transition to: {final_next_section_and_step}"
|
||||
)
|
||||
|
|
@ -817,7 +878,9 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0"
|
|||
db.session.add(activity_state)
|
||||
db.session.commit()
|
||||
|
||||
user_language = activity_state.dict_metadata.get("language", "English")
|
||||
user_language = activity_state.dict_metadata.get(
|
||||
"language", "English"
|
||||
)
|
||||
|
||||
# Emit the transition content blocks if they exist (v2.0 with templates & conditions)
|
||||
if "content_blocks" in transition:
|
||||
|
|
@ -828,14 +891,14 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0"
|
|||
max_attempts=activity_state.max_attempts,
|
||||
current_section=activity_state.section_id,
|
||||
current_step=activity_state.step_id,
|
||||
username=username
|
||||
username=username,
|
||||
)
|
||||
|
||||
# Filter and render content blocks (supports conditional blocks and templates)
|
||||
filtered_blocks = filter_content_blocks(
|
||||
transition["content_blocks"],
|
||||
activity_state.dict_metadata,
|
||||
context
|
||||
context,
|
||||
)
|
||||
|
||||
if filtered_blocks:
|
||||
|
|
@ -878,7 +941,9 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0"
|
|||
user_response,
|
||||
user_language,
|
||||
username,
|
||||
json.dumps(activity_state.dict_metadata), # Pass full metadata
|
||||
json.dumps(
|
||||
activity_state.dict_metadata
|
||||
), # Pass full metadata
|
||||
json.dumps(new_metadata),
|
||||
feedback_tokens_for_ai, # Pass legacy tokens to be combined
|
||||
feedback_model,
|
||||
|
|
@ -941,7 +1006,9 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0"
|
|||
for key, value in transition.get("metadata_append", {}).items():
|
||||
if value == "the-llms-response":
|
||||
# Ensure the key exists and is a list
|
||||
current_value = activity_state.dict_metadata.get(key, [])
|
||||
current_value = activity_state.dict_metadata.get(
|
||||
key, []
|
||||
)
|
||||
if not isinstance(current_value, list):
|
||||
current_value = [current_value]
|
||||
|
||||
|
|
@ -951,7 +1018,9 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0"
|
|||
|
||||
# Track navigation (LAST transition's next_section_and_step wins)
|
||||
if "next_section_and_step" in transition:
|
||||
final_next_section_and_step = transition["next_section_and_step"]
|
||||
final_next_section_and_step = transition[
|
||||
"next_section_and_step"
|
||||
]
|
||||
|
||||
# Track counts_as_attempt (if ANY transition counts, it counts)
|
||||
if transition.get("counts_as_attempt", True):
|
||||
|
|
@ -967,16 +1036,20 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0"
|
|||
max_attempts=activity_state.max_attempts,
|
||||
current_section=activity_state.section_id,
|
||||
current_step=activity_state.step_id,
|
||||
username=username
|
||||
username=username,
|
||||
)
|
||||
hint = get_progressive_hint(
|
||||
step["hints"], activity_state.attempts + 1, context
|
||||
)
|
||||
hint = get_progressive_hint(step["hints"], activity_state.attempts + 1, context)
|
||||
if hint:
|
||||
# Display hint
|
||||
translated_hint = translate_text(hint['text'], user_language, feedback_model)
|
||||
translated_hint = translate_text(
|
||||
hint["text"], user_language, feedback_model
|
||||
)
|
||||
new_message = Message(
|
||||
username="System (Hint)",
|
||||
content=translated_hint,
|
||||
room_id=room.id
|
||||
room_id=room.id,
|
||||
)
|
||||
db.session.add(new_message)
|
||||
db.session.commit()
|
||||
|
|
@ -993,7 +1066,7 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0"
|
|||
socketio.sleep(0.1)
|
||||
|
||||
# If hint doesn't count as attempt, don't increment
|
||||
if not hint['counts_as_attempt']:
|
||||
if not hint["counts_as_attempt"]:
|
||||
any_counts_as_attempt = False
|
||||
|
||||
if (
|
||||
|
|
@ -1011,8 +1084,7 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0"
|
|||
if final_next_section_and_step:
|
||||
# Resolve conditional navigation (v2.0)
|
||||
resolved_navigation = resolve_conditional_navigation(
|
||||
final_next_section_and_step,
|
||||
activity_state.dict_metadata
|
||||
final_next_section_and_step, activity_state.dict_metadata
|
||||
)
|
||||
|
||||
if resolved_navigation:
|
||||
|
|
@ -1051,8 +1123,12 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0"
|
|||
|
||||
# Loop through steps until a question is found or the end is reached
|
||||
loop_through_steps_until_question(
|
||||
activity_content, activity_state, room_name, username,
|
||||
classifier_model=classifier_model, feedback_model=feedback_model
|
||||
activity_content,
|
||||
activity_state,
|
||||
room_name,
|
||||
username,
|
||||
classifier_model=classifier_model,
|
||||
feedback_model=feedback_model,
|
||||
)
|
||||
else:
|
||||
# the user response is any bucket other than correct.
|
||||
|
|
@ -1069,7 +1145,7 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0"
|
|||
max_attempts=activity_state.max_attempts,
|
||||
current_section=activity_state.section_id,
|
||||
current_step=activity_state.step_id,
|
||||
username=username
|
||||
username=username,
|
||||
)
|
||||
question_content = render_template(step["question"], context)
|
||||
translated_question_content = translate_text(
|
||||
|
|
@ -1112,8 +1188,12 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0"
|
|||
else:
|
||||
# Handle steps without a question
|
||||
loop_through_steps_until_question(
|
||||
activity_content, activity_state, room_name, username,
|
||||
classifier_model=classifier_model, feedback_model=feedback_model
|
||||
activity_content,
|
||||
activity_state,
|
||||
room_name,
|
||||
username,
|
||||
classifier_model=classifier_model,
|
||||
feedback_model=feedback_model,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -39,26 +39,32 @@ def render_template(text: str, context: Dict[str, Any]) -> str:
|
|||
return text
|
||||
|
||||
# Find all {{variable}} patterns
|
||||
pattern = r'\{\{([^}]+)\}\}'
|
||||
pattern = r"\{\{([^}]+)\}\}"
|
||||
|
||||
def replace_variable(match):
|
||||
var_name = match.group(1).strip()
|
||||
|
||||
# Handle metadata.key syntax
|
||||
if var_name.startswith('metadata.'):
|
||||
if var_name.startswith("metadata."):
|
||||
key = var_name[9:] # Remove 'metadata.' prefix
|
||||
metadata = context.get('metadata', {})
|
||||
value = metadata.get(key, f'{{{{metadata.{key}}}}}') # Keep original if not found
|
||||
return str(value) if value is not None else ''
|
||||
metadata = context.get("metadata", {})
|
||||
value = metadata.get(
|
||||
key, f"{{{{metadata.{key}}}}}"
|
||||
) # Keep original if not found
|
||||
return str(value) if value is not None else ""
|
||||
|
||||
# Handle built-in variables
|
||||
value = context.get(var_name, f'{{{{{var_name}}}}}') # Keep original if not found
|
||||
return str(value) if value is not None else ''
|
||||
value = context.get(
|
||||
var_name, f"{{{{{var_name}}}}}"
|
||||
) # Keep original if not found
|
||||
return str(value) if value is not None else ""
|
||||
|
||||
return re.sub(pattern, replace_variable, text)
|
||||
|
||||
|
||||
def evaluate_condition(metadata: Dict[str, Any], condition_key: str, condition_value: Any) -> bool:
|
||||
def evaluate_condition(
|
||||
metadata: Dict[str, Any], condition_key: str, condition_value: Any
|
||||
) -> bool:
|
||||
"""
|
||||
Evaluate a single condition against metadata.
|
||||
|
||||
|
|
@ -85,39 +91,39 @@ def evaluate_condition(metadata: Dict[str, Any], condition_key: str, condition_v
|
|||
True if condition met, False otherwise
|
||||
"""
|
||||
# Check for operator suffixes
|
||||
if condition_key.endswith('_ne'):
|
||||
if condition_key.endswith("_ne"):
|
||||
key = condition_key[:-3]
|
||||
return metadata.get(key) != condition_value
|
||||
|
||||
elif condition_key.endswith('_gt'):
|
||||
elif condition_key.endswith("_gt"):
|
||||
key = condition_key[:-3]
|
||||
try:
|
||||
return float(metadata.get(key, 0)) > float(condition_value)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
elif condition_key.endswith('_gte'):
|
||||
elif condition_key.endswith("_gte"):
|
||||
key = condition_key[:-4]
|
||||
try:
|
||||
return float(metadata.get(key, 0)) >= float(condition_value)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
elif condition_key.endswith('_lt'):
|
||||
elif condition_key.endswith("_lt"):
|
||||
key = condition_key[:-3]
|
||||
try:
|
||||
return float(metadata.get(key, 0)) < float(condition_value)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
elif condition_key.endswith('_lte'):
|
||||
elif condition_key.endswith("_lte"):
|
||||
key = condition_key[:-4]
|
||||
try:
|
||||
return float(metadata.get(key, 0)) <= float(condition_value)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
elif condition_key.endswith('_between'):
|
||||
elif condition_key.endswith("_between"):
|
||||
key = condition_key[:-8]
|
||||
if not isinstance(condition_value, list) or len(condition_value) != 2:
|
||||
return False
|
||||
|
|
@ -127,35 +133,35 @@ def evaluate_condition(metadata: Dict[str, Any], condition_key: str, condition_v
|
|||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
elif condition_key.endswith('_not_contains'):
|
||||
elif condition_key.endswith("_not_contains"):
|
||||
key = condition_key[:-13]
|
||||
value_str = str(metadata.get(key, ''))
|
||||
items = [item.strip() for item in value_str.split(',') if item.strip()]
|
||||
value_str = str(metadata.get(key, ""))
|
||||
items = [item.strip() for item in value_str.split(",") if item.strip()]
|
||||
return str(condition_value) not in items
|
||||
|
||||
elif condition_key.endswith('_contains'):
|
||||
elif condition_key.endswith("_contains"):
|
||||
key = condition_key[:-9]
|
||||
value_str = str(metadata.get(key, ''))
|
||||
value_str = str(metadata.get(key, ""))
|
||||
# Split by comma and check if condition_value is in list
|
||||
items = [item.strip() for item in value_str.split(',') if item.strip()]
|
||||
items = [item.strip() for item in value_str.split(",") if item.strip()]
|
||||
return str(condition_value) in items
|
||||
|
||||
elif condition_key.endswith('_matches'):
|
||||
elif condition_key.endswith("_matches"):
|
||||
key = condition_key[:-8]
|
||||
value_str = str(metadata.get(key, ''))
|
||||
value_str = str(metadata.get(key, ""))
|
||||
try:
|
||||
return bool(re.search(str(condition_value), value_str))
|
||||
except re.error:
|
||||
return False
|
||||
|
||||
elif condition_key.endswith('_not_exists'):
|
||||
elif condition_key.endswith("_not_exists"):
|
||||
key = condition_key[:-11]
|
||||
if condition_value:
|
||||
return key not in metadata
|
||||
else:
|
||||
return key in metadata
|
||||
|
||||
elif condition_key.endswith('_exists'):
|
||||
elif condition_key.endswith("_exists"):
|
||||
key = condition_key[:-7]
|
||||
if condition_value:
|
||||
return key in metadata
|
||||
|
|
@ -182,15 +188,14 @@ def check_conditions(metadata: Dict[str, Any], conditions: Dict[str, Any]) -> bo
|
|||
return True
|
||||
|
||||
return all(
|
||||
evaluate_condition(metadata, key, value)
|
||||
for key, value in conditions.items()
|
||||
evaluate_condition(metadata, key, value) for key, value in conditions.items()
|
||||
)
|
||||
|
||||
|
||||
def filter_content_blocks(
|
||||
content_blocks: List[Union[str, Dict[str, Any]]],
|
||||
metadata: Dict[str, Any],
|
||||
context: Dict[str, Any]
|
||||
context: Dict[str, Any],
|
||||
) -> List[str]:
|
||||
"""
|
||||
Filter and render content blocks based on show_if conditions.
|
||||
|
|
@ -217,8 +222,8 @@ def filter_content_blocks(
|
|||
|
||||
elif isinstance(block, dict):
|
||||
# Conditional block - check show_if condition
|
||||
text = block.get('text', '')
|
||||
show_if = block.get('show_if', {})
|
||||
text = block.get("text", "")
|
||||
show_if = block.get("show_if", {})
|
||||
|
||||
# Check if conditions are met
|
||||
if check_conditions(metadata, show_if):
|
||||
|
|
@ -229,8 +234,7 @@ def filter_content_blocks(
|
|||
|
||||
|
||||
def resolve_conditional_navigation(
|
||||
next_section_and_step: Union[str, List[Dict[str, Any]]],
|
||||
metadata: Dict[str, Any]
|
||||
next_section_and_step: Union[str, List[Dict[str, Any]]], metadata: Dict[str, Any]
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Resolve conditional navigation (if/elif/else structure).
|
||||
|
|
@ -249,19 +253,19 @@ def resolve_conditional_navigation(
|
|||
# Conditional branches
|
||||
if isinstance(next_section_and_step, list):
|
||||
for branch in next_section_and_step:
|
||||
if 'if' in branch:
|
||||
if "if" in branch:
|
||||
# if branch
|
||||
if check_conditions(metadata, branch['if']):
|
||||
return branch.get('goto')
|
||||
if check_conditions(metadata, branch["if"]):
|
||||
return branch.get("goto")
|
||||
|
||||
elif 'elif' in branch:
|
||||
elif "elif" in branch:
|
||||
# elif branch
|
||||
if check_conditions(metadata, branch['elif']):
|
||||
return branch.get('goto')
|
||||
if check_conditions(metadata, branch["elif"]):
|
||||
return branch.get("goto")
|
||||
|
||||
elif 'else' in branch:
|
||||
elif "else" in branch:
|
||||
# else branch - always taken if reached
|
||||
return branch.get('goto')
|
||||
return branch.get("goto")
|
||||
|
||||
return None
|
||||
|
||||
|
|
@ -280,8 +284,8 @@ def select_weighted_random(weighted_options: List[Dict[str, Any]]) -> Any:
|
|||
return None
|
||||
|
||||
# Extract values and weights
|
||||
values = [opt['value'] for opt in weighted_options]
|
||||
weights = [opt.get('weight', 1) for opt in weighted_options]
|
||||
values = [opt["value"] for opt in weighted_options]
|
||||
weights = [opt.get("weight", 1) for opt in weighted_options]
|
||||
|
||||
# Use random.choices for weighted selection
|
||||
selected = random.choices(values, weights=weights, k=1)
|
||||
|
|
@ -289,9 +293,7 @@ def select_weighted_random(weighted_options: List[Dict[str, Any]]) -> Any:
|
|||
|
||||
|
||||
def get_progressive_hint(
|
||||
hints: List[Dict[str, Any]],
|
||||
current_attempt: int,
|
||||
context: Dict[str, Any]
|
||||
hints: List[Dict[str, Any]], current_attempt: int, context: Dict[str, Any]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get the hint for the current attempt number, if one exists.
|
||||
|
|
@ -308,12 +310,12 @@ def get_progressive_hint(
|
|||
return None
|
||||
|
||||
for hint in hints:
|
||||
if hint.get('attempt') == current_attempt:
|
||||
if hint.get("attempt") == current_attempt:
|
||||
# Render template variables in hint text
|
||||
hint_text = render_template(hint.get('text', ''), context)
|
||||
hint_text = render_template(hint.get("text", ""), context)
|
||||
return {
|
||||
'text': hint_text,
|
||||
'counts_as_attempt': hint.get('counts_as_attempt', False)
|
||||
"text": hint_text,
|
||||
"counts_as_attempt": hint.get("counts_as_attempt", False),
|
||||
}
|
||||
|
||||
return None
|
||||
|
|
@ -325,7 +327,7 @@ def create_template_context(
|
|||
max_attempts: int,
|
||||
current_section: str,
|
||||
current_step: str,
|
||||
username: str = "User"
|
||||
username: str = "User",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a template rendering context with all built-in variables.
|
||||
|
|
@ -342,11 +344,11 @@ def create_template_context(
|
|||
Context dictionary for template rendering
|
||||
"""
|
||||
return {
|
||||
'metadata': metadata,
|
||||
'current_attempt': current_attempt,
|
||||
'max_attempts': max_attempts,
|
||||
'attempts_remaining': max(0, max_attempts - current_attempt),
|
||||
'current_section': current_section,
|
||||
'current_step': current_step,
|
||||
'username': username
|
||||
"metadata": metadata,
|
||||
"current_attempt": current_attempt,
|
||||
"max_attempts": max_attempts,
|
||||
"attempts_remaining": max(0, max_attempts - current_attempt),
|
||||
"current_section": current_section,
|
||||
"current_step": current_step,
|
||||
"username": username,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,11 +42,17 @@ class ActivityYAMLValidator:
|
|||
|
||||
# Regex patterns for template validation
|
||||
# Jinja2 control structures (NOT ALLOWED)
|
||||
self.jinja2_control_pattern = re.compile(r'\{%\s*(if|for|elif|else|endif|endfor|block|endblock|macro|endmacro|set|include|extends)\s')
|
||||
self.jinja2_control_pattern = re.compile(
|
||||
r"\{%\s*(if|for|elif|else|endif|endfor|block|endblock|macro|endmacro|set|include|extends)\s"
|
||||
)
|
||||
# Handlebars control structures (NOT ALLOWED)
|
||||
self.handlebars_control_pattern = re.compile(r'\{\{#(if|each|unless|with)|\{\{/(if|each|unless|with)\}\}|\{\{else\}\}')
|
||||
self.handlebars_control_pattern = re.compile(
|
||||
r"\{\{#(if|each|unless|with)|\{\{/(if|each|unless|with)\}\}|\{\{else\}\}"
|
||||
)
|
||||
# Valid substitution patterns (ALLOWED)
|
||||
self.valid_substitution_pattern = re.compile(r'\{\{[a-zA-Z_][a-zA-Z0-9_\.]*\}\}')
|
||||
self.valid_substitution_pattern = re.compile(
|
||||
r"\{\{[a-zA-Z_][a-zA-Z0-9_\.]*\}\}"
|
||||
)
|
||||
|
||||
def _check_template_syntax(self, text: str, location: str):
|
||||
"""
|
||||
|
|
@ -123,6 +129,7 @@ class ActivityYAMLValidator:
|
|||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
self.errors.append(f"Unexpected error: {e}")
|
||||
self.errors.append(f"Traceback: {traceback.format_exc()}")
|
||||
return False, self.errors, self.warnings
|
||||
|
|
@ -282,28 +289,27 @@ class ActivityYAMLValidator:
|
|||
if isinstance(block, str):
|
||||
# Simple string block - check for control structures
|
||||
self._check_template_syntax(
|
||||
block,
|
||||
f"Section {section_id}, step {step_id}: content_blocks[{i}]"
|
||||
block, f"Section {section_id}, step {step_id}: content_blocks[{i}]"
|
||||
)
|
||||
elif isinstance(block, dict):
|
||||
# Conditional block (v2.0)
|
||||
if 'text' not in block:
|
||||
if "text" not in block:
|
||||
self.errors.append(
|
||||
f"Section {section_id}, step {step_id}: content_blocks[{i}] dict must have 'text' field"
|
||||
)
|
||||
elif not isinstance(block['text'], str):
|
||||
elif not isinstance(block["text"], str):
|
||||
self.errors.append(
|
||||
f"Section {section_id}, step {step_id}: content_blocks[{i}]['text'] must be a string"
|
||||
)
|
||||
else:
|
||||
# Check text for control structures
|
||||
self._check_template_syntax(
|
||||
block['text'],
|
||||
f"Section {section_id}, step {step_id}: content_blocks[{i}]['text']"
|
||||
block["text"],
|
||||
f"Section {section_id}, step {step_id}: content_blocks[{i}]['text']",
|
||||
)
|
||||
|
||||
if 'show_if' in block:
|
||||
if not isinstance(block['show_if'], dict):
|
||||
if "show_if" in block:
|
||||
if not isinstance(block["show_if"], dict):
|
||||
self.errors.append(
|
||||
f"Section {section_id}, step {step_id}: content_blocks[{i}]['show_if'] must be a dict"
|
||||
)
|
||||
|
|
@ -325,7 +331,7 @@ class ActivityYAMLValidator:
|
|||
# Check question for control structures
|
||||
self._check_template_syntax(
|
||||
step["question"],
|
||||
f"Section {section_id}, step {step_id}: 'question'"
|
||||
f"Section {section_id}, step {step_id}: 'question'",
|
||||
)
|
||||
|
||||
# Validate AI tokens
|
||||
|
|
@ -338,7 +344,7 @@ class ActivityYAMLValidator:
|
|||
# Check tokens_for_ai for control structures
|
||||
self._check_template_syntax(
|
||||
step["tokens_for_ai"],
|
||||
f"Section {section_id}, step {step_id}: 'tokens_for_ai'"
|
||||
f"Section {section_id}, step {step_id}: 'tokens_for_ai'",
|
||||
)
|
||||
|
||||
if "feedback_tokens_for_ai" in step:
|
||||
|
|
@ -350,7 +356,7 @@ class ActivityYAMLValidator:
|
|||
# Check feedback_tokens_for_ai for control structures
|
||||
self._check_template_syntax(
|
||||
step["feedback_tokens_for_ai"],
|
||||
f"Section {section_id}, step {step_id}: 'feedback_tokens_for_ai'"
|
||||
f"Section {section_id}, step {step_id}: 'feedback_tokens_for_ai'",
|
||||
)
|
||||
|
||||
# Validate feedback_prompts (new multi-prompt system)
|
||||
|
|
@ -433,7 +439,7 @@ class ActivityYAMLValidator:
|
|||
# Check for control structures
|
||||
self._check_template_syntax(
|
||||
prompt["tokens_for_ai"],
|
||||
f"Section {section_id}, step {step_id}: feedback_prompts[{i}].tokens_for_ai"
|
||||
f"Section {section_id}, step {step_id}: feedback_prompts[{i}].tokens_for_ai",
|
||||
)
|
||||
# Check for STFU token usage (informational)
|
||||
if "STFU" in prompt["tokens_for_ai"]:
|
||||
|
|
@ -474,7 +480,11 @@ class ActivityYAMLValidator:
|
|||
)
|
||||
|
||||
def _validate_random_buckets(
|
||||
self, random_buckets: Dict[str, Any], buckets: List[str], section_id: str, step_id: str
|
||||
self,
|
||||
random_buckets: Dict[str, Any],
|
||||
buckets: List[str],
|
||||
section_id: str,
|
||||
step_id: str,
|
||||
):
|
||||
"""Validate random_buckets configuration"""
|
||||
if not isinstance(random_buckets, dict):
|
||||
|
|
@ -519,7 +529,8 @@ class ActivityYAMLValidator:
|
|||
total_prob = sum(
|
||||
config.get("probability", 0)
|
||||
for config in random_buckets.values()
|
||||
if isinstance(config, dict) and isinstance(config.get("probability"), (int, float))
|
||||
if isinstance(config, dict)
|
||||
and isinstance(config.get("probability"), (int, float))
|
||||
)
|
||||
if total_prob > 1.0:
|
||||
self.warnings.append(
|
||||
|
|
@ -580,7 +591,9 @@ class ActivityYAMLValidator:
|
|||
)
|
||||
elif isinstance(next_step, list):
|
||||
# Conditional navigation (v2.0)
|
||||
self._validate_conditional_navigation(next_step, bucket, section_id, step_id)
|
||||
self._validate_conditional_navigation(
|
||||
next_step, bucket, section_id, step_id
|
||||
)
|
||||
else:
|
||||
self.errors.append(
|
||||
f"Section {section_id}, step {step_id}, bucket {bucket}: 'next_section_and_step' must be a string or list"
|
||||
|
|
@ -657,7 +670,7 @@ class ActivityYAMLValidator:
|
|||
# Check ai_feedback tokens for control structures
|
||||
self._check_template_syntax(
|
||||
ai_feedback["tokens_for_ai"],
|
||||
f"Section {section_id}, step {step_id}, bucket {bucket}: ai_feedback.tokens_for_ai"
|
||||
f"Section {section_id}, step {step_id}, bucket {bucket}: ai_feedback.tokens_for_ai",
|
||||
)
|
||||
|
||||
if "content_blocks" in transition:
|
||||
|
|
@ -667,9 +680,13 @@ class ActivityYAMLValidator:
|
|||
)
|
||||
else:
|
||||
# v2.0: content_blocks can be strings or dicts with text/show_if
|
||||
self._validate_content_blocks(transition["content_blocks"], section_id, f"{step_id}:{bucket}")
|
||||
self._validate_content_blocks(
|
||||
transition["content_blocks"], section_id, f"{step_id}:{bucket}"
|
||||
)
|
||||
|
||||
def _validate_hints(self, hints: List[Dict[str, Any]], section_id: str, step_id: str):
|
||||
def _validate_hints(
|
||||
self, hints: List[Dict[str, Any]], section_id: str, step_id: str
|
||||
):
|
||||
"""Validate progressive hints system (v2.0)"""
|
||||
if not isinstance(hints, list):
|
||||
self.errors.append(
|
||||
|
|
@ -691,32 +708,34 @@ class ActivityYAMLValidator:
|
|||
continue
|
||||
|
||||
# Validate required fields
|
||||
if 'attempt' not in hint:
|
||||
if "attempt" not in hint:
|
||||
self.errors.append(
|
||||
f"Section {section_id}, step {step_id}: hints[{i}] missing required field 'attempt'"
|
||||
)
|
||||
elif not isinstance(hint['attempt'], int) or hint['attempt'] < 1:
|
||||
elif not isinstance(hint["attempt"], int) or hint["attempt"] < 1:
|
||||
self.errors.append(
|
||||
f"Section {section_id}, step {step_id}: hints[{i}]['attempt'] must be a positive integer"
|
||||
)
|
||||
|
||||
if 'text' not in hint:
|
||||
if "text" not in hint:
|
||||
self.errors.append(
|
||||
f"Section {section_id}, step {step_id}: hints[{i}] missing required field 'text'"
|
||||
)
|
||||
elif not isinstance(hint['text'], str):
|
||||
elif not isinstance(hint["text"], str):
|
||||
self.errors.append(
|
||||
f"Section {section_id}, step {step_id}: hints[{i}]['text'] must be a string"
|
||||
)
|
||||
else:
|
||||
# Check hint text for control structures
|
||||
self._check_template_syntax(
|
||||
hint['text'],
|
||||
f"Section {section_id}, step {step_id}: hints[{i}]['text']"
|
||||
hint["text"],
|
||||
f"Section {section_id}, step {step_id}: hints[{i}]['text']",
|
||||
)
|
||||
|
||||
# Validate optional fields
|
||||
if 'counts_as_attempt' in hint and not isinstance(hint['counts_as_attempt'], bool):
|
||||
if "counts_as_attempt" in hint and not isinstance(
|
||||
hint["counts_as_attempt"], bool
|
||||
):
|
||||
self.errors.append(
|
||||
f"Section {section_id}, step {step_id}: hints[{i}]['counts_as_attempt'] must be a boolean"
|
||||
)
|
||||
|
|
@ -740,17 +759,17 @@ class ActivityYAMLValidator:
|
|||
continue
|
||||
|
||||
# Check for if/elif/else
|
||||
if 'if' in branch:
|
||||
if not isinstance(branch['if'], dict):
|
||||
if "if" in branch:
|
||||
if not isinstance(branch["if"], dict):
|
||||
self.errors.append(
|
||||
f"Section {section_id}, step {step_id}, bucket {bucket}: navigation[{i}]['if'] must be a dict"
|
||||
)
|
||||
elif 'elif' in branch:
|
||||
if not isinstance(branch['elif'], dict):
|
||||
elif "elif" in branch:
|
||||
if not isinstance(branch["elif"], dict):
|
||||
self.errors.append(
|
||||
f"Section {section_id}, step {step_id}, bucket {bucket}: navigation[{i}]['elif'] must be a dict"
|
||||
)
|
||||
elif 'else' in branch:
|
||||
elif "else" in branch:
|
||||
has_else = True
|
||||
# else doesn't need conditions
|
||||
else:
|
||||
|
|
@ -759,15 +778,15 @@ class ActivityYAMLValidator:
|
|||
)
|
||||
|
||||
# Check for goto
|
||||
if 'goto' not in branch:
|
||||
if "goto" not in branch:
|
||||
self.errors.append(
|
||||
f"Section {section_id}, step {step_id}, bucket {bucket}: navigation[{i}] missing required field 'goto'"
|
||||
)
|
||||
elif not isinstance(branch['goto'], str):
|
||||
elif not isinstance(branch["goto"], str):
|
||||
self.errors.append(
|
||||
f"Section {section_id}, step {step_id}, bucket {bucket}: navigation[{i}]['goto'] must be a string"
|
||||
)
|
||||
elif ':' not in branch['goto']:
|
||||
elif ":" not in branch["goto"]:
|
||||
self.errors.append(
|
||||
f"Section {section_id}, step {step_id}, bucket {bucket}: navigation[{i}]['goto'] must be in format 'section_id:step_id'"
|
||||
)
|
||||
|
|
@ -914,7 +933,10 @@ class ActivityYAMLValidator:
|
|||
# Check if any transition continues the flow
|
||||
has_continuing_transition = False
|
||||
for transition in step["transitions"].values():
|
||||
if isinstance(transition, dict) and "next_section_and_step" in transition:
|
||||
if (
|
||||
isinstance(transition, dict)
|
||||
and "next_section_and_step" in transition
|
||||
):
|
||||
# v2.0: next_section_and_step can be string or list (conditional)
|
||||
next_step_value = transition["next_section_and_step"]
|
||||
if next_step_value: # Not None or empty
|
||||
|
|
@ -968,7 +990,10 @@ class ActivityYAMLValidator:
|
|||
for bucket, transition in step["transitions"].items():
|
||||
if "metadata_feedback_filter" in transition:
|
||||
# Check if step has feedback_tokens_for_ai or feedback_prompts
|
||||
if "feedback_tokens_for_ai" not in step and "feedback_prompts" not in step:
|
||||
if (
|
||||
"feedback_tokens_for_ai" not in step
|
||||
and "feedback_prompts" not in step
|
||||
):
|
||||
self.warnings.append(
|
||||
f"Section {section_id}, step {step_id}: metadata_feedback_filter used but no feedback_tokens_for_ai or feedback_prompts defined"
|
||||
)
|
||||
|
|
@ -1028,7 +1053,10 @@ class ActivityYAMLValidator:
|
|||
continue
|
||||
|
||||
for bucket, transition in step["transitions"].items():
|
||||
if isinstance(transition, dict) and "next_section_and_step" in transition:
|
||||
if (
|
||||
isinstance(transition, dict)
|
||||
and "next_section_and_step" in transition
|
||||
):
|
||||
target = transition["next_section_and_step"]
|
||||
|
||||
# v2.0: target can be string or list (conditional navigation)
|
||||
|
|
@ -1040,8 +1068,8 @@ class ActivityYAMLValidator:
|
|||
elif isinstance(target, list):
|
||||
# Conditional navigation - check all goto targets
|
||||
for branch in target:
|
||||
if isinstance(branch, dict) and 'goto' in branch:
|
||||
goto_target = branch['goto']
|
||||
if isinstance(branch, dict) and "goto" in branch:
|
||||
goto_target = branch["goto"]
|
||||
if goto_target not in all_steps:
|
||||
self.errors.append(
|
||||
f"Section {section_id}, step {step_id}: Invalid conditional navigation target '{goto_target}'"
|
||||
|
|
|
|||
12
app.py
12
app.py
|
|
@ -165,16 +165,22 @@ def get_openai_client_and_model(
|
|||
response = client.models.list()
|
||||
if response.data:
|
||||
actual_model = response.data[0].id
|
||||
print(f"[DEBUG] Using first model from {endpoint}: {actual_model}")
|
||||
print(
|
||||
f"[DEBUG] Using first model from {endpoint}: {actual_model}"
|
||||
)
|
||||
return client, actual_model
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not query models from {endpoint}: {e}")
|
||||
|
||||
# Final fallback
|
||||
print(f"Warning: No models found for {endpoint}, using 'model' as fallback")
|
||||
print(
|
||||
f"Warning: No models found for {endpoint}, using 'model' as fallback"
|
||||
)
|
||||
return client, "model"
|
||||
else:
|
||||
print(f"Warning: MODEL_{model_num} not configured ({endpoint_key} or {api_key_key} missing)")
|
||||
print(
|
||||
f"Warning: MODEL_{model_num} not configured ({endpoint_key} or {api_key_key} missing)"
|
||||
)
|
||||
# Fall back to default model
|
||||
model_name = "adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic"
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ from activity_utils import (
|
|||
resolve_conditional_navigation,
|
||||
select_weighted_random,
|
||||
get_progressive_hint,
|
||||
create_template_context
|
||||
create_template_context,
|
||||
)
|
||||
|
||||
# Global model-client mapping
|
||||
|
|
@ -48,13 +48,17 @@ def initialize_model_map():
|
|||
try:
|
||||
response = client.models.list()
|
||||
model_list = response.data
|
||||
print(f"[DEBUG] {endpoint} returned models: {[m.id for m in model_list]}")
|
||||
print(
|
||||
f"[DEBUG] {endpoint} returned models: {[m.id for m in model_list]}"
|
||||
)
|
||||
for m in model_list:
|
||||
model_id = m.id
|
||||
if model_id and model_id not in MODEL_CLIENT_MAP:
|
||||
MODEL_CLIENT_MAP[model_id] = (client, endpoint)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not list models for endpoint '{endpoint}': {e}")
|
||||
print(
|
||||
f"Warning: Could not list models for endpoint '{endpoint}': {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to initialize endpoint {endpoint}: {e}")
|
||||
|
||||
|
|
@ -94,13 +98,17 @@ def get_openai_client_and_model(model_name=None):
|
|||
response = client.models.list()
|
||||
if response.data:
|
||||
actual_model = response.data[0].id
|
||||
print(f"[DEBUG] Using first model from {endpoint}: {actual_model}")
|
||||
print(
|
||||
f"[DEBUG] Using first model from {endpoint}: {actual_model}"
|
||||
)
|
||||
return client, actual_model
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not query models from {endpoint}: {e}")
|
||||
|
||||
# Final fallback
|
||||
print(f"Warning: No models found for {endpoint}, using 'model' as fallback")
|
||||
print(
|
||||
f"Warning: No models found for {endpoint}, using 'model' as fallback"
|
||||
)
|
||||
return client, "model"
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to load {model_name}: {e}, falling back to default")
|
||||
|
|
@ -168,7 +176,9 @@ def categorize_response(question, response, buckets, tokens_for_ai, model="MODEL
|
|||
|
||||
|
||||
# Generate AI feedback
|
||||
def generate_ai_feedback(category, question, user_response, tokens_for_ai, metadata, model="MODEL_1"):
|
||||
def generate_ai_feedback(
|
||||
category, question, user_response, tokens_for_ai, metadata, model="MODEL_1"
|
||||
):
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
|
|
@ -271,7 +281,12 @@ def provide_feedback_prompts(
|
|||
filtered_user_response = "" # Remove user response if not in filter
|
||||
|
||||
ai_feedback = generate_ai_feedback(
|
||||
category, question, filtered_user_response, tokens_for_ai, prompt_metadata, model
|
||||
category,
|
||||
question,
|
||||
filtered_user_response,
|
||||
tokens_for_ai,
|
||||
prompt_metadata,
|
||||
model,
|
||||
)
|
||||
|
||||
# Only add feedback if it has content and isn't exactly the STFU token
|
||||
|
|
@ -392,20 +407,20 @@ def simulate_activity(yaml_file_path):
|
|||
max_attempts=step_max_attempts,
|
||||
current_section=current_section_id,
|
||||
current_step=current_step_id,
|
||||
username="User"
|
||||
username="User",
|
||||
)
|
||||
|
||||
# Translate and print all content blocks once per step (v2.0 with templates & conditionals)
|
||||
if "content_blocks" in step:
|
||||
# Filter and render content blocks
|
||||
filtered_blocks = filter_content_blocks(
|
||||
step["content_blocks"],
|
||||
metadata,
|
||||
context
|
||||
step["content_blocks"], metadata, context
|
||||
)
|
||||
if filtered_blocks:
|
||||
content = "\n\n".join(filtered_blocks)
|
||||
translated_content = translate_text(content, user_language, feedback_model)
|
||||
translated_content = translate_text(
|
||||
content, user_language, feedback_model
|
||||
)
|
||||
print(translated_content)
|
||||
|
||||
# Skip classification and feedback if there's no question
|
||||
|
|
@ -428,7 +443,7 @@ def simulate_activity(yaml_file_path):
|
|||
max_attempts=step_max_attempts,
|
||||
current_section=current_section_id,
|
||||
current_step=current_step_id,
|
||||
username="User"
|
||||
username="User",
|
||||
)
|
||||
|
||||
user_response = input("\nYour Response: ")
|
||||
|
|
@ -441,9 +456,13 @@ def simulate_activity(yaml_file_path):
|
|||
roll = random.random()
|
||||
if roll < probability:
|
||||
triggered_random_buckets.append(bucket_name)
|
||||
print(f"🎲 [RANDOM EVENT] '{bucket_name}' triggered! (rolled {roll:.3f} < {probability})")
|
||||
print(
|
||||
f"🎲 [RANDOM EVENT] '{bucket_name}' triggered! (rolled {roll:.3f} < {probability})"
|
||||
)
|
||||
else:
|
||||
print(f"🎲 [RANDOM CHECK] '{bucket_name}' not triggered (rolled {roll:.3f} >= {probability})")
|
||||
print(
|
||||
f"🎲 [RANDOM CHECK] '{bucket_name}' not triggered (rolled {roll:.3f} >= {probability})"
|
||||
)
|
||||
|
||||
# Execute pre-script if it exists (runs before categorization, with user_response available)
|
||||
if "pre_script" in step:
|
||||
|
|
@ -461,7 +480,11 @@ def simulate_activity(yaml_file_path):
|
|||
print(f"DEBUG: Pre-script completed, updated metadata")
|
||||
|
||||
category = categorize_response(
|
||||
question, user_response, step["buckets"], step["tokens_for_ai"], classifier_model
|
||||
question,
|
||||
user_response,
|
||||
step["buckets"],
|
||||
step["tokens_for_ai"],
|
||||
classifier_model,
|
||||
)
|
||||
print(f"\nCategory: {category}")
|
||||
|
||||
|
|
@ -519,11 +542,12 @@ def simulate_activity(yaml_file_path):
|
|||
# Check metadata conditions (v2.0 advanced conditions)
|
||||
if "metadata_conditions" in transition:
|
||||
conditions_met = check_conditions(
|
||||
metadata,
|
||||
transition["metadata_conditions"]
|
||||
metadata, transition["metadata_conditions"]
|
||||
)
|
||||
if not conditions_met:
|
||||
print(f"⚠️ Skipping '{bucket_name}' - metadata conditions not met")
|
||||
print(
|
||||
f"⚠️ Skipping '{bucket_name}' - metadata conditions not met"
|
||||
)
|
||||
print(f"Current Metadata: {json.dumps(metadata, indent=2)}")
|
||||
continue
|
||||
|
||||
|
|
@ -536,14 +560,12 @@ def simulate_activity(yaml_file_path):
|
|||
max_attempts=max_attempts,
|
||||
current_section=current_section_id,
|
||||
current_step=current_step_id,
|
||||
username="User"
|
||||
username="User",
|
||||
)
|
||||
|
||||
# Filter and render content blocks (supports conditional blocks and templates)
|
||||
filtered_blocks = filter_content_blocks(
|
||||
transition["content_blocks"],
|
||||
metadata,
|
||||
context
|
||||
transition["content_blocks"], metadata, context
|
||||
)
|
||||
|
||||
if filtered_blocks:
|
||||
|
|
@ -570,7 +592,9 @@ def simulate_activity(yaml_file_path):
|
|||
if value.startswith("n+,") or value.startswith("n-,"):
|
||||
# String concatenation: append/remove from existing value
|
||||
operation = value[:2] # "n+" or "n-"
|
||||
suffix = value[3:] # Everything after "n+," or "n-,"
|
||||
suffix = value[
|
||||
3:
|
||||
] # Everything after "n+," or "n-,"
|
||||
existing_value = metadata.get(key, "")
|
||||
if operation == "n+":
|
||||
# Append with comma separator if existing value is non-empty
|
||||
|
|
@ -595,7 +619,9 @@ def simulate_activity(yaml_file_path):
|
|||
elif value.startswith("n-"):
|
||||
value = metadata.get(key, 0) - c
|
||||
except ValueError:
|
||||
print(f"Warning: Invalid numeric operation '{value}' for key '{key}'")
|
||||
print(
|
||||
f"Warning: Invalid numeric operation '{value}' for key '{key}'"
|
||||
)
|
||||
# Leave value as-is if parsing fails
|
||||
metadata[key] = value
|
||||
|
||||
|
|
@ -615,7 +641,9 @@ def simulate_activity(yaml_file_path):
|
|||
if value.startswith("n+,") or value.startswith("n-,"):
|
||||
# String concatenation: append/remove from existing value
|
||||
operation = value[:2] # "n+" or "n-"
|
||||
suffix = value[3:] # Everything after "n+," or "n-,"
|
||||
suffix = value[
|
||||
3:
|
||||
] # Everything after "n+," or "n-,"
|
||||
existing_value = metadata.get(key, "")
|
||||
if operation == "n+":
|
||||
# Append with comma separator if existing value is non-empty
|
||||
|
|
@ -640,7 +668,9 @@ def simulate_activity(yaml_file_path):
|
|||
elif value.startswith("n-"):
|
||||
value = metadata.get(key, 0) - c
|
||||
except ValueError:
|
||||
print(f"Warning: Invalid numeric operation '{value}' for key '{key}'")
|
||||
print(
|
||||
f"Warning: Invalid numeric operation '{value}' for key '{key}'"
|
||||
)
|
||||
# Leave value as-is if parsing fails
|
||||
metadata[key] = value
|
||||
metadata_tmp_keys.append(key) # Track temporary keys
|
||||
|
|
@ -651,12 +681,17 @@ def simulate_activity(yaml_file_path):
|
|||
del metadata[key]
|
||||
|
||||
# Handle metadata_clear - clear all metadata if set to True
|
||||
if "metadata_clear" in transition and transition["metadata_clear"] == True:
|
||||
if (
|
||||
"metadata_clear" in transition
|
||||
and transition["metadata_clear"] == True
|
||||
):
|
||||
metadata.clear()
|
||||
|
||||
# Handle metadata_random
|
||||
if "metadata_random" in transition:
|
||||
random_key = random.choice(list(transition["metadata_random"].keys()))
|
||||
random_key = random.choice(
|
||||
list(transition["metadata_random"].keys())
|
||||
)
|
||||
random_value = transition["metadata_random"][random_key]
|
||||
metadata[random_key] = random_value
|
||||
|
||||
|
|
@ -664,19 +699,25 @@ def simulate_activity(yaml_file_path):
|
|||
random_key = random.choice(
|
||||
list(transition["metadata_tmp_random"].keys())
|
||||
)
|
||||
random_value = random.choice(transition["metadata_tmp_random"][random_key])
|
||||
random_value = random.choice(
|
||||
transition["metadata_tmp_random"][random_key]
|
||||
)
|
||||
metadata[random_key] = random_value
|
||||
metadata_tmp_keys.append(random_key) # Track temporary keys
|
||||
|
||||
# Handle metadata_weighted_random (v2.0)
|
||||
if "metadata_weighted_random" in transition:
|
||||
for key, weighted_options in transition["metadata_weighted_random"].items():
|
||||
for key, weighted_options in transition[
|
||||
"metadata_weighted_random"
|
||||
].items():
|
||||
selected_value = select_weighted_random(weighted_options)
|
||||
metadata[key] = selected_value
|
||||
|
||||
# Handle metadata_tmp_weighted_random (v2.0)
|
||||
if "metadata_tmp_weighted_random" in transition:
|
||||
for key, weighted_options in transition["metadata_tmp_weighted_random"].items():
|
||||
for key, weighted_options in transition[
|
||||
"metadata_tmp_weighted_random"
|
||||
].items():
|
||||
selected_value = select_weighted_random(weighted_options)
|
||||
metadata[key] = selected_value
|
||||
metadata_tmp_keys.append(key)
|
||||
|
|
@ -704,7 +745,9 @@ def simulate_activity(yaml_file_path):
|
|||
for key, value in result.get("metadata", {}).items():
|
||||
metadata[key] = value
|
||||
|
||||
print(f"\n[Metadata after '{bucket_name}']: {json.dumps(metadata, indent=2)}")
|
||||
print(
|
||||
f"\n[Metadata after '{bucket_name}']: {json.dumps(metadata, indent=2)}"
|
||||
)
|
||||
|
||||
# Provide feedback for THIS bucket
|
||||
if "feedback_prompts" in step:
|
||||
|
|
@ -759,14 +802,16 @@ def simulate_activity(yaml_file_path):
|
|||
max_attempts=step_max_attempts,
|
||||
current_section=current_section_id,
|
||||
current_step=current_step_id,
|
||||
username="User"
|
||||
username="User",
|
||||
)
|
||||
hint = get_progressive_hint(step["hints"], attempts + 1, hint_context)
|
||||
if hint:
|
||||
translated_hint = translate_text(hint['text'], user_language, feedback_model)
|
||||
translated_hint = translate_text(
|
||||
hint["text"], user_language, feedback_model
|
||||
)
|
||||
print(f"\n💡 Hint: {translated_hint}")
|
||||
# If hint doesn't count as attempt, adjust counting
|
||||
if not hint['counts_as_attempt']:
|
||||
if not hint["counts_as_attempt"]:
|
||||
any_counts_as_attempt = False
|
||||
|
||||
# Check if we should break or continue attempting
|
||||
|
|
@ -795,8 +840,7 @@ def simulate_activity(yaml_file_path):
|
|||
# v2.0: Resolve conditional navigation
|
||||
if final_next_section_and_step:
|
||||
resolved_navigation = resolve_conditional_navigation(
|
||||
final_next_section_and_step,
|
||||
metadata
|
||||
final_next_section_and_step, metadata
|
||||
)
|
||||
if resolved_navigation:
|
||||
current_section_id, current_step_id = resolved_navigation.split(":")
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"""
|
||||
Comprehensive activity flow tests that exercise all transitions
|
||||
|
||||
These tests run complete activity walkthroughs to validate that all
|
||||
These tests run complete activity walkthroughs to validate that all
|
||||
transitions work correctly, especially after our YAML changes.
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ class TestActivityIntegration(unittest.TestCase):
|
|||
|
||||
# Create a fresh Flask app for testing
|
||||
from flask import Flask
|
||||
|
||||
test_app = Flask(__name__)
|
||||
test_app.config["TESTING"] = True
|
||||
test_app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
|
||||
|
|
@ -149,14 +150,14 @@ sections:
|
|||
"""
|
||||
# Write to research directory
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode='w', suffix='.yaml', dir='research', delete=False
|
||||
mode="w", suffix=".yaml", dir="research", delete=False
|
||||
) as f:
|
||||
f.write(activity_content)
|
||||
# Return just the filename (not the full path)
|
||||
return os.path.basename(f.name), room
|
||||
|
||||
@patch('activity.socketio')
|
||||
@patch('activity.get_openai_client_and_model')
|
||||
@patch("activity.socketio")
|
||||
@patch("activity.get_openai_client_and_model")
|
||||
def test_start_activity(self, mock_get_client, mock_socketio):
|
||||
"""Test starting an activity creates proper state"""
|
||||
from models import ActivityState
|
||||
|
|
@ -179,7 +180,7 @@ sections:
|
|||
self.assertEqual(state.step_id, "step_1")
|
||||
self.assertEqual(state.attempts, 0)
|
||||
|
||||
@patch('activity.socketio')
|
||||
@patch("activity.socketio")
|
||||
def test_cancel_activity(self, mock_socketio):
|
||||
"""Test canceling an activity"""
|
||||
from models import ActivityState, Room
|
||||
|
|
@ -194,7 +195,7 @@ sections:
|
|||
room_id=room.id,
|
||||
section_id="test_section",
|
||||
step_id="test_step",
|
||||
s3_file_path="test.yaml"
|
||||
s3_file_path="test.yaml",
|
||||
)
|
||||
self.db.session.add(state)
|
||||
self.db.session.commit()
|
||||
|
|
@ -209,7 +210,7 @@ sections:
|
|||
# Verify socket event was emitted
|
||||
mock_socketio.emit.assert_called()
|
||||
|
||||
@patch('activity.socketio')
|
||||
@patch("activity.socketio")
|
||||
def test_display_activity_metadata(self, mock_socketio):
|
||||
"""Test displaying activity metadata"""
|
||||
from models import ActivityState, Room
|
||||
|
|
@ -224,7 +225,7 @@ sections:
|
|||
room_id=room.id,
|
||||
section_id="test_section",
|
||||
step_id="test_step",
|
||||
s3_file_path="test.yaml"
|
||||
s3_file_path="test.yaml",
|
||||
)
|
||||
state.add_metadata("score", 100)
|
||||
state.add_metadata("level", 5)
|
||||
|
|
@ -241,9 +242,11 @@ sections:
|
|||
self.assertIn("chat_message", str(call_args))
|
||||
self.assertIn("score", str(call_args)) or self.assertIn("level", str(call_args))
|
||||
|
||||
@patch('activity.socketio')
|
||||
@patch('activity.get_openai_client_and_model')
|
||||
def test_handle_activity_response_correct_answer(self, mock_get_client, mock_socketio):
|
||||
@patch("activity.socketio")
|
||||
@patch("activity.get_openai_client_and_model")
|
||||
def test_handle_activity_response_correct_answer(
|
||||
self, mock_get_client, mock_socketio
|
||||
):
|
||||
"""Test handling a correct answer advances to next step"""
|
||||
from models import ActivityState
|
||||
import activity
|
||||
|
|
@ -256,7 +259,7 @@ sections:
|
|||
room_id=room.id,
|
||||
section_id="section_1",
|
||||
step_id="step_1",
|
||||
s3_file_path=f"research/{filename}"
|
||||
s3_file_path=f"research/{filename}",
|
||||
)
|
||||
self.db.session.add(state)
|
||||
self.db.session.commit()
|
||||
|
|
@ -277,12 +280,16 @@ sections:
|
|||
|
||||
# Verify state advanced to next step
|
||||
updated_state = ActivityState.query.filter_by(room_id=room.id).first()
|
||||
self.assertIsNotNone(updated_state, "ActivityState should still exist after correct answer")
|
||||
self.assertIsNotNone(
|
||||
updated_state, "ActivityState should still exist after correct answer"
|
||||
)
|
||||
self.assertEqual(updated_state.step_id, "step_2")
|
||||
|
||||
@patch('activity.socketio')
|
||||
@patch('activity.get_openai_client_and_model')
|
||||
def test_handle_activity_response_increments_attempts(self, mock_get_client, mock_socketio):
|
||||
@patch("activity.socketio")
|
||||
@patch("activity.get_openai_client_and_model")
|
||||
def test_handle_activity_response_increments_attempts(
|
||||
self, mock_get_client, mock_socketio
|
||||
):
|
||||
"""Test that incorrect answers increment attempt counter"""
|
||||
from models import ActivityState
|
||||
import activity
|
||||
|
|
@ -295,7 +302,7 @@ sections:
|
|||
room_id=room.id,
|
||||
section_id="section_1",
|
||||
step_id="step_1",
|
||||
s3_file_path=f"research/{filename}"
|
||||
s3_file_path=f"research/{filename}",
|
||||
)
|
||||
self.db.session.add(state)
|
||||
self.db.session.commit()
|
||||
|
|
@ -322,7 +329,7 @@ sections:
|
|||
# Should still be on same step
|
||||
self.assertEqual(updated_state.step_id, "step_1")
|
||||
|
||||
@patch('activity.socketio')
|
||||
@patch("activity.socketio")
|
||||
def test_execute_processing_script_with_metadata_operations(self, mock_socketio):
|
||||
"""Test processing script that modifies metadata"""
|
||||
from models import ActivityState, Room
|
||||
|
|
@ -334,10 +341,7 @@ sections:
|
|||
self.db.session.commit()
|
||||
|
||||
state = ActivityState(
|
||||
room_id=room.id,
|
||||
section_id="test",
|
||||
step_id="test",
|
||||
s3_file_path="test.yaml"
|
||||
room_id=room.id, section_id="test", step_id="test", s3_file_path="test.yaml"
|
||||
)
|
||||
state.add_metadata("counter", 0)
|
||||
self.db.session.add(state)
|
||||
|
|
@ -353,8 +357,8 @@ script_result = metadata['counter']
|
|||
|
||||
self.assertEqual(result, 1)
|
||||
|
||||
@patch('activity.socketio')
|
||||
@patch('activity.get_openai_client_and_model')
|
||||
@patch("activity.socketio")
|
||||
@patch("activity.get_openai_client_and_model")
|
||||
def test_loop_through_steps_until_question(self, mock_get_client, mock_socketio):
|
||||
"""Test looping through info steps until reaching a question"""
|
||||
from models import ActivityState
|
||||
|
|
@ -380,13 +384,14 @@ sections:
|
|||
- bucket_name: "yes"
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode='w', suffix='.yaml', dir='research', delete=False
|
||||
mode="w", suffix=".yaml", dir="research", delete=False
|
||||
) as f:
|
||||
f.write(activity_content)
|
||||
filename = os.path.basename(f.name)
|
||||
|
||||
# Create room
|
||||
from models import Room
|
||||
|
||||
room = Room(name="test_room")
|
||||
self.db.session.add(room)
|
||||
self.db.session.commit()
|
||||
|
|
@ -396,7 +401,7 @@ sections:
|
|||
room_id=room.id,
|
||||
section_id="intro",
|
||||
step_id="info_1",
|
||||
s3_file_path=f"research/{filename}"
|
||||
s3_file_path=f"research/{filename}",
|
||||
)
|
||||
self.db.session.add(state)
|
||||
self.db.session.commit()
|
||||
|
|
@ -409,9 +414,7 @@ sections:
|
|||
mock_get_client.return_value = (mock_client, "qwen-2.5-72b")
|
||||
|
||||
# Loop through steps
|
||||
activity.loop_through_steps_until_question(
|
||||
content, state, room.name, "alice"
|
||||
)
|
||||
activity.loop_through_steps_until_question(content, state, room.name, "alice")
|
||||
|
||||
# Should have advanced to question_1
|
||||
updated_state = ActivityState.query.filter_by(room_id=room.id).first()
|
||||
|
|
@ -472,10 +475,7 @@ class TestActivityMetadataOperations(unittest.TestCase):
|
|||
|
||||
# Create state with metadata
|
||||
state = ActivityState(
|
||||
room_id=room.id,
|
||||
section_id="test",
|
||||
step_id="test",
|
||||
s3_file_path="test.yaml"
|
||||
room_id=room.id, section_id="test", step_id="test", s3_file_path="test.yaml"
|
||||
)
|
||||
state.add_metadata("score", 100)
|
||||
state.add_metadata("level", 5)
|
||||
|
|
@ -500,10 +500,7 @@ class TestActivityMetadataOperations(unittest.TestCase):
|
|||
self.db.session.commit()
|
||||
|
||||
state = ActivityState(
|
||||
room_id=room.id,
|
||||
section_id="test",
|
||||
step_id="test",
|
||||
s3_file_path="test.yaml"
|
||||
room_id=room.id, section_id="test", step_id="test", s3_file_path="test.yaml"
|
||||
)
|
||||
state.add_metadata("temp", "value")
|
||||
state.add_metadata("keep", "important")
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ class TestDatabaseModelsIntegration(unittest.TestCase):
|
|||
step_id="step_1",
|
||||
s3_file_path="activity.yaml",
|
||||
attempts=0,
|
||||
max_attempts=3
|
||||
max_attempts=3,
|
||||
)
|
||||
self.db.session.add(state)
|
||||
self.db.session.commit()
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ class TestGetActivityContent(unittest.TestCase):
|
|||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
# Mock app config
|
||||
self.app_patcher = patch('activity.app')
|
||||
self.app_patcher = patch("activity.app")
|
||||
self.mock_app = self.app_patcher.start()
|
||||
|
||||
def tearDown(self):
|
||||
|
|
@ -46,7 +46,9 @@ class TestGetActivityContent(unittest.TestCase):
|
|||
# Create a temporary YAML file
|
||||
test_content = {"sections": [{"section_id": "test"}]}
|
||||
|
||||
with patch('builtins.open', unittest.mock.mock_open(read_data=yaml.dump(test_content))):
|
||||
with patch(
|
||||
"builtins.open", unittest.mock.mock_open(read_data=yaml.dump(test_content))
|
||||
):
|
||||
result = get_activity_content("research/test_activity.yaml")
|
||||
|
||||
self.assertEqual(result["sections"][0]["section_id"], "test")
|
||||
|
|
@ -100,6 +102,7 @@ class TestExecuteProcessingScript(unittest.TestCase):
|
|||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
from activity import execute_processing_script
|
||||
|
||||
self.execute_processing_script = execute_processing_script
|
||||
|
||||
def test_execute_processing_script_simple(self):
|
||||
|
|
@ -123,7 +126,7 @@ else:
|
|||
|
||||
result = self.execute_processing_script(metadata, script)
|
||||
|
||||
self.assertEqual(result, 'healthy')
|
||||
self.assertEqual(result, "healthy")
|
||||
|
||||
def test_execute_processing_script_none_result(self):
|
||||
"""Test script that doesn't set result"""
|
||||
|
|
@ -159,6 +162,7 @@ class TestGetNextStep(unittest.TestCase):
|
|||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
from activity import get_next_step
|
||||
|
||||
self.get_next_step = get_next_step
|
||||
|
||||
# Sample activity content
|
||||
|
|
@ -170,15 +174,15 @@ class TestGetNextStep(unittest.TestCase):
|
|||
{"step_id": "step_1"},
|
||||
{"step_id": "step_2"},
|
||||
{"step_id": "step_3"},
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
"section_id": "section_2",
|
||||
"steps": [
|
||||
{"step_id": "step_4"},
|
||||
{"step_id": "step_5"},
|
||||
]
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -231,7 +235,7 @@ class TestGetNextStep(unittest.TestCase):
|
|||
class TestCategorizeResponse(unittest.TestCase):
|
||||
"""Test cases for categorize_response function"""
|
||||
|
||||
@patch('activity.get_openai_client_and_model')
|
||||
@patch("activity.get_openai_client_and_model")
|
||||
def test_categorize_response_simple_format(self, mock_get_client):
|
||||
"""Test categorization with simple bucket format"""
|
||||
from activity import categorize_response
|
||||
|
|
@ -246,19 +250,16 @@ class TestCategorizeResponse(unittest.TestCase):
|
|||
|
||||
buckets = [
|
||||
{"bucket_name": "correct", "bucket_criteria": "Answer is correct"},
|
||||
{"bucket_name": "incorrect", "bucket_criteria": "Answer is wrong"}
|
||||
{"bucket_name": "incorrect", "bucket_criteria": "Answer is wrong"},
|
||||
]
|
||||
|
||||
result = categorize_response(
|
||||
"What is 2+2?",
|
||||
"4",
|
||||
buckets,
|
||||
"Categorize this answer"
|
||||
"What is 2+2?", "4", buckets, "Categorize this answer"
|
||||
)
|
||||
|
||||
self.assertEqual(result, "correct")
|
||||
|
||||
@patch('activity.get_openai_client_and_model')
|
||||
@patch("activity.get_openai_client_and_model")
|
||||
def test_categorize_response_analysis_format(self, mock_get_client):
|
||||
"""Test categorization with analysis bucket format"""
|
||||
from activity import categorize_response
|
||||
|
|
@ -274,19 +275,16 @@ class TestCategorizeResponse(unittest.TestCase):
|
|||
|
||||
buckets = [
|
||||
{"bucket_name": "correct", "bucket_criteria": "Answer is correct"},
|
||||
{"bucket_name": "incorrect", "bucket_criteria": "Answer is wrong"}
|
||||
{"bucket_name": "incorrect", "bucket_criteria": "Answer is wrong"},
|
||||
]
|
||||
|
||||
result = categorize_response(
|
||||
"What is 2+2?",
|
||||
"4",
|
||||
buckets,
|
||||
"Categorize this answer"
|
||||
"What is 2+2?", "4", buckets, "Categorize this answer"
|
||||
)
|
||||
|
||||
self.assertEqual(result, "correct")
|
||||
|
||||
@patch('activity.get_openai_client_and_model')
|
||||
@patch("activity.get_openai_client_and_model")
|
||||
def test_categorize_response_with_spaces(self, mock_get_client):
|
||||
"""Test categorization handles extra spaces"""
|
||||
from activity import categorize_response
|
||||
|
|
@ -308,7 +306,7 @@ class TestCategorizeResponse(unittest.TestCase):
|
|||
class TestGenerateAIFeedback(unittest.TestCase):
|
||||
"""Test cases for generate_ai_feedback function"""
|
||||
|
||||
@patch('activity.get_openai_client_and_model')
|
||||
@patch("activity.get_openai_client_and_model")
|
||||
def test_generate_ai_feedback(self, mock_get_client):
|
||||
"""Test generating AI feedback"""
|
||||
from activity import generate_ai_feedback
|
||||
|
|
@ -328,12 +326,12 @@ class TestGenerateAIFeedback(unittest.TestCase):
|
|||
"Provide encouraging feedback",
|
||||
"alice",
|
||||
"{}",
|
||||
"{}"
|
||||
"{}",
|
||||
)
|
||||
|
||||
self.assertEqual(result, "Great answer!")
|
||||
|
||||
@patch('activity.get_openai_client_and_model')
|
||||
@patch("activity.get_openai_client_and_model")
|
||||
def test_generate_ai_feedback_with_metadata(self, mock_get_client):
|
||||
"""Test feedback generation with metadata"""
|
||||
from activity import generate_ai_feedback
|
||||
|
|
@ -348,23 +346,17 @@ class TestGenerateAIFeedback(unittest.TestCase):
|
|||
metadata = json.dumps({"score": 100, "level": 5})
|
||||
|
||||
result = generate_ai_feedback(
|
||||
"correct",
|
||||
"Question",
|
||||
"Answer",
|
||||
"Tokens",
|
||||
"alice",
|
||||
metadata,
|
||||
"{}"
|
||||
"correct", "Question", "Answer", "Tokens", "alice", metadata, "{}"
|
||||
)
|
||||
|
||||
# Verify metadata was included in the call
|
||||
call_args = mock_client.chat.completions.create.call_args
|
||||
messages = call_args[1]['messages']
|
||||
messages = call_args[1]["messages"]
|
||||
|
||||
# Check that metadata is in one of the messages
|
||||
found_metadata = False
|
||||
for msg in messages:
|
||||
if 'score' in str(msg) and '100' in str(msg):
|
||||
if "score" in str(msg) and "100" in str(msg):
|
||||
found_metadata = True
|
||||
break
|
||||
|
||||
|
|
@ -374,7 +366,7 @@ class TestGenerateAIFeedback(unittest.TestCase):
|
|||
class TestTranslateText(unittest.TestCase):
|
||||
"""Test cases for translate_text function"""
|
||||
|
||||
@patch('activity.get_openai_client_and_model')
|
||||
@patch("activity.get_openai_client_and_model")
|
||||
def test_translate_text_to_spanish(self, mock_get_client):
|
||||
"""Test translating text to Spanish"""
|
||||
from activity import translate_text
|
||||
|
|
@ -391,7 +383,7 @@ class TestTranslateText(unittest.TestCase):
|
|||
|
||||
self.assertEqual(result, "Hola mundo")
|
||||
|
||||
@patch('activity.get_openai_client_and_model')
|
||||
@patch("activity.get_openai_client_and_model")
|
||||
def test_translate_text_english_bypass(self, mock_get_client):
|
||||
"""Test that English text is not translated"""
|
||||
from activity import translate_text
|
||||
|
|
@ -402,7 +394,7 @@ class TestTranslateText(unittest.TestCase):
|
|||
self.assertEqual(result, "Hello world")
|
||||
mock_get_client.assert_not_called()
|
||||
|
||||
@patch('activity.get_openai_client_and_model')
|
||||
@patch("activity.get_openai_client_and_model")
|
||||
def test_translate_text_error_handling(self, mock_get_client):
|
||||
"""Test translation error handling"""
|
||||
from activity import translate_text
|
||||
|
|
@ -421,16 +413,14 @@ class TestTranslateText(unittest.TestCase):
|
|||
class TestProvideFeedback(unittest.TestCase):
|
||||
"""Test cases for provide_feedback function"""
|
||||
|
||||
@patch('activity.generate_ai_feedback')
|
||||
@patch("activity.generate_ai_feedback")
|
||||
def test_provide_feedback_with_ai_feedback(self, mock_generate):
|
||||
"""Test providing feedback with AI feedback enabled"""
|
||||
from activity import provide_feedback
|
||||
|
||||
mock_generate.return_value = "Good job!"
|
||||
|
||||
transition = {
|
||||
"ai_feedback": {"tokens_for_ai": "Be encouraging"}
|
||||
}
|
||||
transition = {"ai_feedback": {"tokens_for_ai": "Be encouraging"}}
|
||||
|
||||
result = provide_feedback(
|
||||
transition,
|
||||
|
|
@ -441,7 +431,7 @@ class TestProvideFeedback(unittest.TestCase):
|
|||
"English",
|
||||
"alice",
|
||||
"{}",
|
||||
"{}"
|
||||
"{}",
|
||||
)
|
||||
|
||||
self.assertIn("Good job!", result)
|
||||
|
|
@ -461,7 +451,7 @@ class TestProvideFeedback(unittest.TestCase):
|
|||
"English",
|
||||
"alice",
|
||||
"{}",
|
||||
"{}"
|
||||
"{}",
|
||||
)
|
||||
|
||||
self.assertEqual(result, "")
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from activity_utils import (
|
|||
resolve_conditional_navigation,
|
||||
select_weighted_random,
|
||||
get_progressive_hint,
|
||||
create_template_context
|
||||
create_template_context,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -37,7 +37,9 @@ class TestRenderTemplate:
|
|||
def test_metadata_variable(self):
|
||||
"""Test metadata.key syntax"""
|
||||
context = {"metadata": {"player_name": "Alice", "level": 5}}
|
||||
result = render_template("Player: {{metadata.player_name}}, Level: {{metadata.level}}", context)
|
||||
result = render_template(
|
||||
"Player: {{metadata.player_name}}, Level: {{metadata.level}}", context
|
||||
)
|
||||
assert result == "Player: Alice, Level: 5"
|
||||
|
||||
def test_built_in_variables(self):
|
||||
|
|
@ -48,11 +50,11 @@ class TestRenderTemplate:
|
|||
"attempts_remaining": 1,
|
||||
"current_section": "intro",
|
||||
"current_step": "welcome",
|
||||
"username": "Bob"
|
||||
"username": "Bob",
|
||||
}
|
||||
result = render_template(
|
||||
"Attempt {{current_attempt}}/{{max_attempts}} ({{attempts_remaining}} left) - {{username}}",
|
||||
context
|
||||
context,
|
||||
)
|
||||
assert result == "Attempt 2/3 (1 left) - Bob"
|
||||
|
||||
|
|
@ -137,21 +139,52 @@ class TestEvaluateCondition:
|
|||
|
||||
def test_contains(self):
|
||||
"""Test contains operator (_contains) for comma-separated lists"""
|
||||
assert evaluate_condition({"inventory": "sword,shield,potion"}, "inventory_contains", "sword") is True
|
||||
assert evaluate_condition({"inventory": "sword,shield,potion"}, "inventory_contains", "axe") is False
|
||||
assert evaluate_condition({"inventory": "sword"}, "inventory_contains", "sword") is True
|
||||
assert evaluate_condition({"inventory": ""}, "inventory_contains", "sword") is False
|
||||
assert (
|
||||
evaluate_condition(
|
||||
{"inventory": "sword,shield,potion"}, "inventory_contains", "sword"
|
||||
)
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
evaluate_condition(
|
||||
{"inventory": "sword,shield,potion"}, "inventory_contains", "axe"
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
evaluate_condition({"inventory": "sword"}, "inventory_contains", "sword")
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
evaluate_condition({"inventory": ""}, "inventory_contains", "sword")
|
||||
is False
|
||||
)
|
||||
|
||||
def test_not_contains(self):
|
||||
"""Test not contains operator (_not_contains)"""
|
||||
assert evaluate_condition({"inventory": "sword,shield"}, "inventory_not_contains", "axe") is True
|
||||
assert evaluate_condition({"inventory": "sword,shield"}, "inventory_not_contains", "sword") is False
|
||||
assert (
|
||||
evaluate_condition(
|
||||
{"inventory": "sword,shield"}, "inventory_not_contains", "axe"
|
||||
)
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
evaluate_condition(
|
||||
{"inventory": "sword,shield"}, "inventory_not_contains", "sword"
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_matches(self):
|
||||
"""Test regex match operator (_matches)"""
|
||||
assert evaluate_condition({"name": "Alice"}, "name_matches", r"^[A-Z]") is True
|
||||
assert evaluate_condition({"name": "alice"}, "name_matches", r"^[A-Z]") is False
|
||||
assert evaluate_condition({"email": "test@example.com"}, "email_matches", r".*@.*\.com") is True
|
||||
assert (
|
||||
evaluate_condition(
|
||||
{"email": "test@example.com"}, "email_matches", r".*@.*\.com"
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_exists(self):
|
||||
"""Test existence check operator (_exists)"""
|
||||
|
|
@ -163,7 +196,9 @@ class TestEvaluateCondition:
|
|||
def test_not_exists(self):
|
||||
"""Test non-existence check operator (_not_exists)"""
|
||||
assert evaluate_condition({}, "missing_not_exists", True) is True
|
||||
assert evaluate_condition({"has_key": True}, "has_key_not_exists", True) is False
|
||||
assert (
|
||||
evaluate_condition({"has_key": True}, "has_key_not_exists", True) is False
|
||||
)
|
||||
|
||||
def test_invalid_number_comparison(self):
|
||||
"""Test numeric comparison with non-numeric values"""
|
||||
|
|
@ -177,7 +212,9 @@ class TestEvaluateCondition:
|
|||
|
||||
def test_invalid_regex(self):
|
||||
"""Test matches with invalid regex"""
|
||||
assert evaluate_condition({"value": "test"}, "value_matches", "[invalid") is False
|
||||
assert (
|
||||
evaluate_condition({"value": "test"}, "value_matches", "[invalid") is False
|
||||
)
|
||||
|
||||
|
||||
class TestCheckConditions:
|
||||
|
|
@ -190,20 +227,13 @@ class TestCheckConditions:
|
|||
def test_all_conditions_met(self):
|
||||
"""Test all conditions must be met"""
|
||||
metadata = {"score": 100, "level": 5, "inventory": "sword,shield"}
|
||||
conditions = {
|
||||
"score_gte": 100,
|
||||
"level": 5,
|
||||
"inventory_contains": "sword"
|
||||
}
|
||||
conditions = {"score_gte": 100, "level": 5, "inventory_contains": "sword"}
|
||||
assert check_conditions(metadata, conditions) is True
|
||||
|
||||
def test_some_conditions_not_met(self):
|
||||
"""Test fails if any condition not met"""
|
||||
metadata = {"score": 50, "level": 5}
|
||||
conditions = {
|
||||
"score_gte": 100,
|
||||
"level": 5
|
||||
}
|
||||
conditions = {"score_gte": 100, "level": 5}
|
||||
assert check_conditions(metadata, conditions) is False
|
||||
|
||||
def test_mixed_operators(self):
|
||||
|
|
@ -213,7 +243,7 @@ class TestCheckConditions:
|
|||
"score_gte": 50,
|
||||
"score_lt": 100,
|
||||
"status_ne": "inactive",
|
||||
"name_matches": r"^[A-Z]"
|
||||
"name_matches": r"^[A-Z]",
|
||||
}
|
||||
assert check_conditions(metadata, conditions) is True
|
||||
|
||||
|
|
@ -230,9 +260,7 @@ class TestFilterContentBlocks:
|
|||
|
||||
def test_conditional_block_shown(self):
|
||||
"""Test conditional block shown when condition met"""
|
||||
blocks = [
|
||||
{"text": "High score!", "show_if": {"score_gte": 50}}
|
||||
]
|
||||
blocks = [{"text": "High score!", "show_if": {"score_gte": 50}}]
|
||||
metadata = {"score": 100}
|
||||
context = {"metadata": metadata}
|
||||
result = filter_content_blocks(blocks, metadata, context)
|
||||
|
|
@ -240,9 +268,7 @@ class TestFilterContentBlocks:
|
|||
|
||||
def test_conditional_block_hidden(self):
|
||||
"""Test conditional block hidden when condition not met"""
|
||||
blocks = [
|
||||
{"text": "High score!", "show_if": {"score_gte": 50}}
|
||||
]
|
||||
blocks = [{"text": "High score!", "show_if": {"score_gte": 50}}]
|
||||
metadata = {"score": 20}
|
||||
context = {"metadata": metadata}
|
||||
result = filter_content_blocks(blocks, metadata, context)
|
||||
|
|
@ -254,7 +280,7 @@ class TestFilterContentBlocks:
|
|||
"Always shown",
|
||||
{"text": "High score!", "show_if": {"score_gte": 50}},
|
||||
{"text": "Low score", "show_if": {"score_lt": 50}},
|
||||
"Also always shown"
|
||||
"Also always shown",
|
||||
]
|
||||
metadata = {"score": 75}
|
||||
context = {"metadata": metadata}
|
||||
|
|
@ -265,7 +291,7 @@ class TestFilterContentBlocks:
|
|||
"""Test that templates are rendered in filtered blocks"""
|
||||
blocks = [
|
||||
"Score: {{metadata.score}}",
|
||||
{"text": "Level: {{metadata.level}}", "show_if": {"level_gte": 1}}
|
||||
{"text": "Level: {{metadata.level}}", "show_if": {"level_gte": 1}},
|
||||
]
|
||||
metadata = {"score": 100, "level": 5}
|
||||
context = {"metadata": metadata}
|
||||
|
|
@ -290,7 +316,7 @@ class TestResolveConditionalNavigation:
|
|||
"""Test if branch when condition matches"""
|
||||
nav = [
|
||||
{"if": {"score_gte": 100}, "goto": "expert:challenge"},
|
||||
{"else": {}, "goto": "beginner:tutorial"}
|
||||
{"else": {}, "goto": "beginner:tutorial"},
|
||||
]
|
||||
metadata = {"score": 150}
|
||||
result = resolve_conditional_navigation(nav, metadata)
|
||||
|
|
@ -301,7 +327,7 @@ class TestResolveConditionalNavigation:
|
|||
nav = [
|
||||
{"if": {"score_gte": 100}, "goto": "expert:challenge"},
|
||||
{"elif": {"score_gte": 50}, "goto": "intermediate:lesson"},
|
||||
{"else": {}, "goto": "beginner:tutorial"}
|
||||
{"else": {}, "goto": "beginner:tutorial"},
|
||||
]
|
||||
metadata = {"score": 75}
|
||||
result = resolve_conditional_navigation(nav, metadata)
|
||||
|
|
@ -312,7 +338,7 @@ class TestResolveConditionalNavigation:
|
|||
nav = [
|
||||
{"if": {"score_gte": 100}, "goto": "expert:challenge"},
|
||||
{"elif": {"score_gte": 50}, "goto": "intermediate:lesson"},
|
||||
{"else": {}, "goto": "beginner:tutorial"}
|
||||
{"else": {}, "goto": "beginner:tutorial"},
|
||||
]
|
||||
metadata = {"score": 20}
|
||||
result = resolve_conditional_navigation(nav, metadata)
|
||||
|
|
@ -322,7 +348,7 @@ class TestResolveConditionalNavigation:
|
|||
"""Test returns None when no conditions match and no else"""
|
||||
nav = [
|
||||
{"if": {"score_gte": 100}, "goto": "expert:challenge"},
|
||||
{"elif": {"score_gte": 50}, "goto": "intermediate:lesson"}
|
||||
{"elif": {"score_gte": 50}, "goto": "intermediate:lesson"},
|
||||
]
|
||||
metadata = {"score": 20}
|
||||
result = resolve_conditional_navigation(nav, metadata)
|
||||
|
|
@ -332,7 +358,7 @@ class TestResolveConditionalNavigation:
|
|||
"""Test branch with multiple conditions (AND logic)"""
|
||||
nav = [
|
||||
{"if": {"score_gte": 100, "level_gte": 10}, "goto": "expert:challenge"},
|
||||
{"else": {}, "goto": "beginner:tutorial"}
|
||||
{"else": {}, "goto": "beginner:tutorial"},
|
||||
]
|
||||
metadata = {"score": 100, "level": 10}
|
||||
result = resolve_conditional_navigation(nav, metadata)
|
||||
|
|
@ -342,7 +368,7 @@ class TestResolveConditionalNavigation:
|
|||
"""Test that first matching branch is used"""
|
||||
nav = [
|
||||
{"if": {"score_gte": 50}, "goto": "first:path"},
|
||||
{"elif": {"score_gte": 50}, "goto": "second:path"}
|
||||
{"elif": {"score_gte": 50}, "goto": "second:path"},
|
||||
]
|
||||
metadata = {"score": 75}
|
||||
result = resolve_conditional_navigation(nav, metadata)
|
||||
|
|
@ -357,7 +383,7 @@ class TestSelectWeightedRandom:
|
|||
options = [
|
||||
{"value": "common", "weight": 70},
|
||||
{"value": "rare", "weight": 25},
|
||||
{"value": "legendary", "weight": 5}
|
||||
{"value": "legendary", "weight": 5},
|
||||
]
|
||||
|
||||
# Run multiple times and check distribution is roughly correct
|
||||
|
|
@ -368,8 +394,8 @@ class TestSelectWeightedRandom:
|
|||
|
||||
# Allow 10% variance from expected distribution
|
||||
assert 600 < common_count < 800 # Expected ~700
|
||||
assert 150 < rare_count < 350 # Expected ~250
|
||||
assert 0 < legendary_count < 100 # Expected ~50
|
||||
assert 150 < rare_count < 350 # Expected ~250
|
||||
assert 0 < legendary_count < 100 # Expected ~50
|
||||
|
||||
def test_single_option(self):
|
||||
"""Test selection with single option"""
|
||||
|
|
@ -382,7 +408,7 @@ class TestSelectWeightedRandom:
|
|||
options = [
|
||||
{"value": "a", "weight": 1},
|
||||
{"value": "b", "weight": 1},
|
||||
{"value": "c", "weight": 1}
|
||||
{"value": "c", "weight": 1},
|
||||
]
|
||||
results = [select_weighted_random(options) for _ in range(300)]
|
||||
# Each should appear roughly 100 times (allow variance)
|
||||
|
|
@ -397,10 +423,7 @@ class TestSelectWeightedRandom:
|
|||
|
||||
def test_missing_weight(self):
|
||||
"""Test option with missing weight defaults to 1"""
|
||||
options = [
|
||||
{"value": "a", "weight": 10},
|
||||
{"value": "b"} # No weight
|
||||
]
|
||||
options = [{"value": "a", "weight": 10}, {"value": "b"}] # No weight
|
||||
# Should not crash
|
||||
result = select_weighted_random(options)
|
||||
assert result in ["a", "b"]
|
||||
|
|
@ -414,7 +437,7 @@ class TestGetProgressiveHint:
|
|||
hints = [
|
||||
{"attempt": 1, "text": "First hint", "counts_as_attempt": False},
|
||||
{"attempt": 2, "text": "Second hint", "counts_as_attempt": False},
|
||||
{"attempt": 3, "text": "Third hint", "counts_as_attempt": False}
|
||||
{"attempt": 3, "text": "Third hint", "counts_as_attempt": False},
|
||||
]
|
||||
context = {}
|
||||
result = get_progressive_hint(hints, 2, context)
|
||||
|
|
@ -422,9 +445,7 @@ class TestGetProgressiveHint:
|
|||
|
||||
def test_no_hint_for_attempt(self):
|
||||
"""Test returns None when no hint for attempt"""
|
||||
hints = [
|
||||
{"attempt": 1, "text": "First hint", "counts_as_attempt": False}
|
||||
]
|
||||
hints = [{"attempt": 1, "text": "First hint", "counts_as_attempt": False}]
|
||||
result = get_progressive_hint(hints, 2, {})
|
||||
assert result is None
|
||||
|
||||
|
|
@ -436,7 +457,11 @@ class TestGetProgressiveHint:
|
|||
def test_template_rendering_in_hint(self):
|
||||
"""Test that templates are rendered in hint text"""
|
||||
hints = [
|
||||
{"attempt": 1, "text": "Attempt {{current_attempt}} of {{max_attempts}}", "counts_as_attempt": False}
|
||||
{
|
||||
"attempt": 1,
|
||||
"text": "Attempt {{current_attempt}} of {{max_attempts}}",
|
||||
"counts_as_attempt": False,
|
||||
}
|
||||
]
|
||||
context = {"current_attempt": 1, "max_attempts": 3}
|
||||
result = get_progressive_hint(hints, 1, context)
|
||||
|
|
@ -444,17 +469,13 @@ class TestGetProgressiveHint:
|
|||
|
||||
def test_counts_as_attempt_field(self):
|
||||
"""Test counts_as_attempt field is preserved"""
|
||||
hints = [
|
||||
{"attempt": 1, "text": "Hint", "counts_as_attempt": True}
|
||||
]
|
||||
hints = [{"attempt": 1, "text": "Hint", "counts_as_attempt": True}]
|
||||
result = get_progressive_hint(hints, 1, {})
|
||||
assert result["counts_as_attempt"] is True
|
||||
|
||||
def test_missing_counts_as_attempt(self):
|
||||
"""Test missing counts_as_attempt defaults to False"""
|
||||
hints = [
|
||||
{"attempt": 1, "text": "Hint"}
|
||||
]
|
||||
hints = [{"attempt": 1, "text": "Hint"}]
|
||||
result = get_progressive_hint(hints, 1, {})
|
||||
assert result["counts_as_attempt"] is False
|
||||
|
||||
|
|
@ -471,7 +492,7 @@ class TestCreateTemplateContext:
|
|||
max_attempts=3,
|
||||
current_section="intro",
|
||||
current_step="welcome",
|
||||
username="Alice"
|
||||
username="Alice",
|
||||
)
|
||||
|
||||
assert context["metadata"] == metadata
|
||||
|
|
@ -490,7 +511,7 @@ class TestCreateTemplateContext:
|
|||
max_attempts=3,
|
||||
current_section="s",
|
||||
current_step="st",
|
||||
username="User"
|
||||
username="User",
|
||||
)
|
||||
assert context["attempts_remaining"] == 2
|
||||
|
||||
|
|
@ -502,7 +523,7 @@ class TestCreateTemplateContext:
|
|||
max_attempts=3,
|
||||
current_section="s",
|
||||
current_step="st",
|
||||
username="User"
|
||||
username="User",
|
||||
)
|
||||
assert context["attempts_remaining"] == 0
|
||||
|
||||
|
|
@ -513,7 +534,7 @@ class TestCreateTemplateContext:
|
|||
current_attempt=1,
|
||||
max_attempts=3,
|
||||
current_section="s",
|
||||
current_step="st"
|
||||
current_step="st",
|
||||
)
|
||||
assert context["username"] == "User"
|
||||
|
||||
|
|
@ -524,8 +545,11 @@ class TestIntegration:
|
|||
def test_template_and_conditions_together(self):
|
||||
"""Test templates work with conditions in content blocks"""
|
||||
blocks = [
|
||||
{"text": "Welcome {{metadata.player_name}}!", "show_if": {"player_name_exists": True}},
|
||||
{"text": "Score: {{metadata.score}}", "show_if": {"score_gte": 0}}
|
||||
{
|
||||
"text": "Welcome {{metadata.player_name}}!",
|
||||
"show_if": {"player_name_exists": True},
|
||||
},
|
||||
{"text": "Score: {{metadata.score}}", "show_if": {"score_gte": 0}},
|
||||
]
|
||||
metadata = {"player_name": "Alice", "score": 50}
|
||||
context = create_template_context(
|
||||
|
|
@ -534,7 +558,7 @@ class TestIntegration:
|
|||
max_attempts=3,
|
||||
current_section="intro",
|
||||
current_step="welcome",
|
||||
username="Alice"
|
||||
username="Alice",
|
||||
)
|
||||
|
||||
# Add exists condition to metadata for testing
|
||||
|
|
@ -549,16 +573,10 @@ class TestIntegration:
|
|||
nav = [
|
||||
{
|
||||
"if": {"score_gte": 100, "level_gte": 10, "inventory_contains": "key"},
|
||||
"goto": "secret:room"
|
||||
"goto": "secret:room",
|
||||
},
|
||||
{
|
||||
"elif": {"score_gte": 50},
|
||||
"goto": "intermediate:level"
|
||||
},
|
||||
{
|
||||
"else": {},
|
||||
"goto": "beginner:start"
|
||||
}
|
||||
{"elif": {"score_gte": 50}, "goto": "intermediate:level"},
|
||||
{"else": {}, "goto": "beginner:start"},
|
||||
]
|
||||
|
||||
# Test first branch
|
||||
|
|
|
|||
|
|
@ -812,7 +812,6 @@ sections:
|
|||
finally:
|
||||
os.unlink(warning_file)
|
||||
|
||||
|
||||
def test_jinja2_control_structures_rejected(self):
|
||||
"""Test that Jinja2 control structures are rejected"""
|
||||
jinja2_control_yaml = """
|
||||
|
|
@ -847,7 +846,12 @@ sections:
|
|||
self.assertGreater(len(jinja2_errors), 0)
|
||||
# Check that error messages mention the right thing
|
||||
self.assertTrue(any("NOT supported" in error for error in jinja2_errors))
|
||||
self.assertTrue(any("show_if" in error or "pre-compute" in error for error in jinja2_errors))
|
||||
self.assertTrue(
|
||||
any(
|
||||
"show_if" in error or "pre-compute" in error
|
||||
for error in jinja2_errors
|
||||
)
|
||||
)
|
||||
finally:
|
||||
os.unlink(temp_file)
|
||||
|
||||
|
|
@ -881,7 +885,9 @@ sections:
|
|||
handlebars_errors = [e for e in errors if "Handlebars" in e]
|
||||
self.assertGreater(len(handlebars_errors), 0)
|
||||
# Check that error messages mention the right thing
|
||||
self.assertTrue(any("NOT supported" in error for error in handlebars_errors))
|
||||
self.assertTrue(
|
||||
any("NOT supported" in error for error in handlebars_errors)
|
||||
)
|
||||
finally:
|
||||
os.unlink(temp_file)
|
||||
|
||||
|
|
@ -925,7 +931,10 @@ sections:
|
|||
temp_file = self.create_temp_yaml(valid_substitutions_yaml)
|
||||
try:
|
||||
is_valid, errors, warnings = self.validator.validate_file(temp_file)
|
||||
self.assertTrue(is_valid, f"Valid substitutions should be allowed but got errors: {errors}")
|
||||
self.assertTrue(
|
||||
is_valid,
|
||||
f"Valid substitutions should be allowed but got errors: {errors}",
|
||||
)
|
||||
self.assertEqual(len(errors), 0)
|
||||
finally:
|
||||
os.unlink(temp_file)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ class TestRoomModel(unittest.TestCase):
|
|||
"""Set up test fixtures"""
|
||||
# Import here to avoid issues
|
||||
from models import Room
|
||||
|
||||
self.Room = Room
|
||||
|
||||
def create_room(self, name="test_room", title=None):
|
||||
|
|
@ -141,6 +142,7 @@ class TestUserSessionModel(unittest.TestCase):
|
|||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
from models import UserSession
|
||||
|
||||
self.UserSession = UserSession
|
||||
|
||||
def test_user_session_creation(self):
|
||||
|
|
@ -163,11 +165,12 @@ class TestMessageModel(unittest.TestCase):
|
|||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
from models import Message
|
||||
|
||||
self.Message = Message
|
||||
|
||||
def test_message_creation(self):
|
||||
"""Test creating a message"""
|
||||
with patch('models.tiktoken.encoding_for_model') as mock_encoding:
|
||||
with patch("models.tiktoken.encoding_for_model") as mock_encoding:
|
||||
mock_enc = MagicMock()
|
||||
mock_enc.encode.return_value = [1, 2, 3, 4, 5] # 5 tokens
|
||||
mock_encoding.return_value = mock_enc
|
||||
|
|
@ -181,7 +184,7 @@ class TestMessageModel(unittest.TestCase):
|
|||
|
||||
def test_count_tokens(self):
|
||||
"""Test token counting for text messages"""
|
||||
with patch('models.tiktoken.encoding_for_model') as mock_encoding:
|
||||
with patch("models.tiktoken.encoding_for_model") as mock_encoding:
|
||||
mock_enc = MagicMock()
|
||||
mock_enc.encode.return_value = [1, 2, 3] # 3 tokens
|
||||
mock_encoding.return_value = mock_enc
|
||||
|
|
@ -194,7 +197,7 @@ class TestMessageModel(unittest.TestCase):
|
|||
|
||||
def test_count_tokens_cached(self):
|
||||
"""Test that token count is cached after first calculation"""
|
||||
with patch('models.tiktoken.encoding_for_model') as mock_encoding:
|
||||
with patch("models.tiktoken.encoding_for_model") as mock_encoding:
|
||||
mock_enc = MagicMock()
|
||||
mock_enc.encode.return_value = [1, 2, 3]
|
||||
mock_encoding.return_value = mock_enc
|
||||
|
|
@ -230,7 +233,7 @@ class TestMessageModel(unittest.TestCase):
|
|||
"""Test that images have zero token count"""
|
||||
content = '<img src="data:image/jpeg;base64,/9j/4AAQSkZJRg...">'
|
||||
|
||||
with patch('models.tiktoken.encoding_for_model') as mock_encoding:
|
||||
with patch("models.tiktoken.encoding_for_model") as mock_encoding:
|
||||
msg = self.Message("alice", content, 1)
|
||||
|
||||
self.assertEqual(msg.token_count, 0)
|
||||
|
|
@ -244,6 +247,7 @@ class TestActivityStateModel(unittest.TestCase):
|
|||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
from models import ActivityState
|
||||
|
||||
self.ActivityState = ActivityState
|
||||
|
||||
def create_activity_state(self):
|
||||
|
|
|
|||
|
|
@ -25,13 +25,9 @@ class TestRandomBucketRolling(unittest.TestCase):
|
|||
|
||||
def test_random_bucket_triggers_when_roll_below_probability(self):
|
||||
"""Test that random bucket triggers when roll < probability"""
|
||||
step = {
|
||||
"random_buckets": {
|
||||
"emergency": {"probability": 0.5}
|
||||
}
|
||||
}
|
||||
step = {"random_buckets": {"emergency": {"probability": 0.5}}}
|
||||
|
||||
with patch('random.random', return_value=0.3): # 0.3 < 0.5
|
||||
with patch("random.random", return_value=0.3): # 0.3 < 0.5
|
||||
triggered_buckets = []
|
||||
for bucket_name, config in step["random_buckets"].items():
|
||||
probability = config.get("probability", 0)
|
||||
|
|
@ -44,13 +40,9 @@ class TestRandomBucketRolling(unittest.TestCase):
|
|||
|
||||
def test_random_bucket_does_not_trigger_when_roll_above_probability(self):
|
||||
"""Test that random bucket doesn't trigger when roll >= probability"""
|
||||
step = {
|
||||
"random_buckets": {
|
||||
"emergency": {"probability": 0.5}
|
||||
}
|
||||
}
|
||||
step = {"random_buckets": {"emergency": {"probability": 0.5}}}
|
||||
|
||||
with patch('random.random', return_value=0.7): # 0.7 >= 0.5
|
||||
with patch("random.random", return_value=0.7): # 0.7 >= 0.5
|
||||
triggered_buckets = []
|
||||
for bucket_name, config in step["random_buckets"].items():
|
||||
probability = config.get("probability", 0)
|
||||
|
|
@ -65,12 +57,12 @@ class TestRandomBucketRolling(unittest.TestCase):
|
|||
step = {
|
||||
"random_buckets": {
|
||||
"emergency": {"probability": 0.5},
|
||||
"task": {"probability": 0.5}
|
||||
"task": {"probability": 0.5},
|
||||
}
|
||||
}
|
||||
|
||||
# Mock random to always return low values
|
||||
with patch('random.random', return_value=0.2): # 0.2 < 0.5 for both
|
||||
with patch("random.random", return_value=0.2): # 0.2 < 0.5 for both
|
||||
triggered_buckets = []
|
||||
for bucket_name, config in step["random_buckets"].items():
|
||||
probability = config.get("probability", 0)
|
||||
|
|
@ -87,7 +79,7 @@ class TestRandomBucketRolling(unittest.TestCase):
|
|||
step = {
|
||||
"random_buckets": {
|
||||
"emergency": {"probability": 0.15},
|
||||
"task": {"probability": 0.15}
|
||||
"task": {"probability": 0.15},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -107,14 +99,18 @@ class TestRandomBucketRolling(unittest.TestCase):
|
|||
|
||||
if len(triggered_buckets) == 2:
|
||||
double_trigger_found = True
|
||||
print(f"✓ Double trigger found on iteration {iterations}: {triggered_buckets}")
|
||||
print(
|
||||
f"✓ Double trigger found on iteration {iterations}: {triggered_buckets}"
|
||||
)
|
||||
break
|
||||
|
||||
# With 15% probability each, chance of both triggering = 0.15 * 0.15 = 0.0225 (2.25%)
|
||||
# Over 20 trials, probability of at least one double = 1 - (1 - 0.0225)^20 ≈ 36%
|
||||
# This test may occasionally fail due to randomness, but should pass most of the time
|
||||
if not double_trigger_found:
|
||||
print(f"⚠️ Warning: No double trigger found in {iterations} iterations (expected ~36% success rate)")
|
||||
print(
|
||||
f"⚠️ Warning: No double trigger found in {iterations} iterations (expected ~36% success rate)"
|
||||
)
|
||||
|
||||
# We don't assert here because random tests can fail
|
||||
# Instead we just report the result
|
||||
|
|
@ -126,7 +122,7 @@ class TestRandomBucketRolling(unittest.TestCase):
|
|||
"random_buckets": {
|
||||
"emergency": {"probability": 1.0}, # 100% to prevent flaky tests
|
||||
"task": {"probability": 1.0}, # 100% to prevent flaky tests
|
||||
"challenge": {"probability": 1.0} # 100% to prevent flaky tests
|
||||
"challenge": {"probability": 1.0}, # 100% to prevent flaky tests
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -146,20 +142,25 @@ class TestRandomBucketRolling(unittest.TestCase):
|
|||
|
||||
if len(triggered_buckets) == 3:
|
||||
triple_trigger_found = True
|
||||
print(f"✓ Triple trigger found on iteration {iterations}: {triggered_buckets}")
|
||||
print(
|
||||
f"✓ Triple trigger found on iteration {iterations}: {triggered_buckets}"
|
||||
)
|
||||
break
|
||||
|
||||
# With 100% probability each, all three should trigger on first iteration
|
||||
self.assertTrue(triple_trigger_found, "Triple trigger should have been found with 100% probabilities")
|
||||
self.assertEqual(iterations, 1, "Triple trigger should happen on first iteration with 100% probabilities")
|
||||
self.assertTrue(
|
||||
triple_trigger_found,
|
||||
"Triple trigger should have been found with 100% probabilities",
|
||||
)
|
||||
self.assertEqual(
|
||||
iterations,
|
||||
1,
|
||||
"Triple trigger should happen on first iteration with 100% probabilities",
|
||||
)
|
||||
|
||||
def test_zero_probability_never_triggers(self):
|
||||
"""Test that 0% probability never triggers"""
|
||||
step = {
|
||||
"random_buckets": {
|
||||
"impossible": {"probability": 0.0}
|
||||
}
|
||||
}
|
||||
step = {"random_buckets": {"impossible": {"probability": 0.0}}}
|
||||
|
||||
# Try 100 times - should never trigger
|
||||
for _ in range(100):
|
||||
|
|
@ -174,11 +175,7 @@ class TestRandomBucketRolling(unittest.TestCase):
|
|||
|
||||
def test_100_percent_probability_always_triggers(self):
|
||||
"""Test that 100% probability always triggers"""
|
||||
step = {
|
||||
"random_buckets": {
|
||||
"guaranteed": {"probability": 1.0}
|
||||
}
|
||||
}
|
||||
step = {"random_buckets": {"guaranteed": {"probability": 1.0}}}
|
||||
|
||||
# Try 10 times - should always trigger
|
||||
for _ in range(10):
|
||||
|
|
@ -310,7 +307,9 @@ class TestStringConcatenationMetadata(unittest.TestCase):
|
|||
else:
|
||||
metadata[key] = suffix
|
||||
|
||||
self.assertEqual(metadata["visited_sections"], "forward_escape_trunk,torpedo_room")
|
||||
self.assertEqual(
|
||||
metadata["visited_sections"], "forward_escape_trunk,torpedo_room"
|
||||
)
|
||||
|
||||
def test_string_append_multiple_times(self):
|
||||
"""Test multiple append operations"""
|
||||
|
|
@ -404,30 +403,30 @@ class TestRandomBucketIntegration(unittest.TestCase):
|
|||
step = {
|
||||
"random_buckets": {
|
||||
"emergency": {"probability": 0.05},
|
||||
"daily_task": {"probability": 0.15}
|
||||
"daily_task": {"probability": 0.15},
|
||||
},
|
||||
"transitions": {
|
||||
"torpedo_room": {
|
||||
"metadata_add": {
|
||||
"current_section": "torpedo_room",
|
||||
"visited_sections": "n+,torpedo_room"
|
||||
"visited_sections": "n+,torpedo_room",
|
||||
},
|
||||
"next_section_and_step": "navigation_hub:torpedo_room"
|
||||
"next_section_and_step": "navigation_hub:torpedo_room",
|
||||
},
|
||||
"emergency": {
|
||||
"metadata_add": {"emergency_active": "true"},
|
||||
"next_section_and_step": "emergency:handle"
|
||||
"next_section_and_step": "emergency:handle",
|
||||
},
|
||||
"daily_task": {
|
||||
"metadata_add": {"task_active": "true"},
|
||||
"next_section_and_step": "task:handle"
|
||||
}
|
||||
}
|
||||
"next_section_and_step": "task:handle",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Simulate one emergency triggering
|
||||
triggered_random_buckets = []
|
||||
with patch('random.random') as mock_random:
|
||||
with patch("random.random") as mock_random:
|
||||
# First call: emergency (0.03 < 0.05) - triggers
|
||||
# Second call: daily_task (0.9 >= 0.15) - doesn't trigger
|
||||
mock_random.side_effect = [0.03, 0.9]
|
||||
|
|
@ -477,22 +476,22 @@ class TestRandomBucketIntegration(unittest.TestCase):
|
|||
step = {
|
||||
"random_buckets": {
|
||||
"emergency": {"probability": 1.0}, # Guaranteed
|
||||
"daily_task": {"probability": 1.0} # Guaranteed
|
||||
"daily_task": {"probability": 1.0}, # Guaranteed
|
||||
},
|
||||
"transitions": {
|
||||
"examine": {
|
||||
"next_section_and_step": "navigation_hub:forward_escape_trunk",
|
||||
"counts_as_attempt": False # Add this so examine doesn't count
|
||||
"counts_as_attempt": False, # Add this so examine doesn't count
|
||||
},
|
||||
"emergency": {
|
||||
"metadata_add": {"emergency_count": "n+1"},
|
||||
"counts_as_attempt": False
|
||||
"counts_as_attempt": False,
|
||||
},
|
||||
"daily_task": {
|
||||
"metadata_add": {"task_count": "n+1"},
|
||||
"counts_as_attempt": False
|
||||
}
|
||||
}
|
||||
"counts_as_attempt": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Both random events trigger (100% probability)
|
||||
|
|
@ -512,7 +511,11 @@ class TestRandomBucketIntegration(unittest.TestCase):
|
|||
|
||||
if "metadata_add" in transition:
|
||||
for key, value in transition["metadata_add"].items():
|
||||
if isinstance(value, str) and value.startswith("n+") and not value.startswith("n+,"):
|
||||
if (
|
||||
isinstance(value, str)
|
||||
and value.startswith("n+")
|
||||
and not value.startswith("n+,")
|
||||
):
|
||||
increment = int(value[2:])
|
||||
metadata[key] = metadata.get(key, 0) + increment
|
||||
|
||||
|
|
@ -537,37 +540,34 @@ class TestRandomBucketIntegration(unittest.TestCase):
|
|||
"random_buckets": {
|
||||
"emergency": {"probability": 1.0}, # Guaranteed
|
||||
"daily_task": {"probability": 1.0}, # Guaranteed
|
||||
"bonus_challenge": {"probability": 1.0} # Guaranteed
|
||||
"bonus_challenge": {"probability": 1.0}, # Guaranteed
|
||||
},
|
||||
"transitions": {
|
||||
"correct_answer": {
|
||||
"metadata_add": {"score": "n+10"},
|
||||
"next_section_and_step": "quiz:next_question",
|
||||
"counts_as_attempt": False
|
||||
"counts_as_attempt": False,
|
||||
},
|
||||
"emergency": {
|
||||
"metadata_add": {
|
||||
"emergency_count": "n+1",
|
||||
"score": "n-5" # Emergency penalty
|
||||
"score": "n-5", # Emergency penalty
|
||||
},
|
||||
"counts_as_attempt": False,
|
||||
"next_section_and_step": "emergency:handle"
|
||||
"next_section_and_step": "emergency:handle",
|
||||
},
|
||||
"daily_task": {
|
||||
"metadata_add": {
|
||||
"task_count": "n+1",
|
||||
"score": "n+2" # Task bonus
|
||||
},
|
||||
"counts_as_attempt": False
|
||||
"metadata_add": {"task_count": "n+1", "score": "n+2"}, # Task bonus
|
||||
"counts_as_attempt": False,
|
||||
},
|
||||
"bonus_challenge": {
|
||||
"metadata_add": {
|
||||
"challenge_count": "n+1",
|
||||
"score": "n+15" # Big bonus
|
||||
"score": "n+15", # Big bonus
|
||||
},
|
||||
"counts_as_attempt": False
|
||||
}
|
||||
}
|
||||
"counts_as_attempt": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# All three random events trigger (100% probability)
|
||||
|
|
@ -589,10 +589,18 @@ class TestRandomBucketIntegration(unittest.TestCase):
|
|||
|
||||
if "metadata_add" in transition:
|
||||
for key, value in transition["metadata_add"].items():
|
||||
if isinstance(value, str) and value.startswith("n+") and not value.startswith("n+,"):
|
||||
if (
|
||||
isinstance(value, str)
|
||||
and value.startswith("n+")
|
||||
and not value.startswith("n+,")
|
||||
):
|
||||
increment = int(value[2:])
|
||||
metadata[key] = metadata.get(key, 0) + increment
|
||||
elif isinstance(value, str) and value.startswith("n-") and not value.startswith("n-,"):
|
||||
elif (
|
||||
isinstance(value, str)
|
||||
and value.startswith("n-")
|
||||
and not value.startswith("n-,")
|
||||
):
|
||||
decrement = int(value[2:])
|
||||
metadata[key] = metadata.get(key, 0) - decrement
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue