Run black formatter on all Python files

Format code according to black style guidelines for consistency
This commit is contained in:
Claude 2025-11-10 19:37:05 +00:00
parent 408b419b94
commit 22db7a9a8a
No known key found for this signature in database
13 changed files with 593 additions and 407 deletions

View file

@ -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:

View file

@ -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,
}

View file

@ -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
View file

@ -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:

View file

@ -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(":")

View file

@ -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.
"""

View file

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

View file

@ -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()

View file

@ -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, "")

View file

@ -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

View file

@ -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)

View 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):

View file

@ -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