From 22db7a9a8a713c6a3eba90c5864531787b7f4d9e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Nov 2025 19:37:05 +0000 Subject: [PATCH] Run black formatter on all Python files Format code according to black style guidelines for consistency --- activity.py | 178 +++++++++++++----- activity_utils.py | 114 +++++------ activity_yaml_validator.py | 110 +++++++---- app.py | 12 +- research/guarded_ai.py | 120 ++++++++---- tests/functional/test_activity_flows.py | 2 +- .../integration/test_activity_integration.py | 69 ++++--- tests/integration/test_app_integration.py | 2 +- tests/unit/test_activity.py | 70 +++---- tests/unit/test_activity_utils.py | 162 +++++++++------- tests/unit/test_activity_yaml_validator.py | 17 +- tests/unit/test_models.py | 12 +- tests/unit/test_random_buckets.py | 132 +++++++------ 13 files changed, 593 insertions(+), 407 deletions(-) diff --git a/activity.py b/activity.py index ce24aff..25fd64d 100644 --- a/activity.py +++ b/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: diff --git a/activity_utils.py b/activity_utils.py index d0a5cd4..59ba4b8 100644 --- a/activity_utils.py +++ b/activity_utils.py @@ -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, } diff --git a/activity_yaml_validator.py b/activity_yaml_validator.py index 06390ad..f48cab5 100644 --- a/activity_yaml_validator.py +++ b/activity_yaml_validator.py @@ -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}'" diff --git a/app.py b/app.py index 500ae51..c083bcc 100644 --- a/app.py +++ b/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: diff --git a/research/guarded_ai.py b/research/guarded_ai.py index c281484..a61a913 100644 --- a/research/guarded_ai.py +++ b/research/guarded_ai.py @@ -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(":") diff --git a/tests/functional/test_activity_flows.py b/tests/functional/test_activity_flows.py index bab1722..ac5bf66 100644 --- a/tests/functional/test_activity_flows.py +++ b/tests/functional/test_activity_flows.py @@ -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. """ diff --git a/tests/integration/test_activity_integration.py b/tests/integration/test_activity_integration.py index b423c34..80e9130 100644 --- a/tests/integration/test_activity_integration.py +++ b/tests/integration/test_activity_integration.py @@ -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") diff --git a/tests/integration/test_app_integration.py b/tests/integration/test_app_integration.py index efbbe14..2f04c57 100644 --- a/tests/integration/test_app_integration.py +++ b/tests/integration/test_app_integration.py @@ -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() diff --git a/tests/unit/test_activity.py b/tests/unit/test_activity.py index 4aa91ad..cb0f63a 100644 --- a/tests/unit/test_activity.py +++ b/tests/unit/test_activity.py @@ -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, "") diff --git a/tests/unit/test_activity_utils.py b/tests/unit/test_activity_utils.py index 83d73cb..9c5b870 100644 --- a/tests/unit/test_activity_utils.py +++ b/tests/unit/test_activity_utils.py @@ -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 diff --git a/tests/unit/test_activity_yaml_validator.py b/tests/unit/test_activity_yaml_validator.py index ec753ee..b9bc68f 100644 --- a/tests/unit/test_activity_yaml_validator.py +++ b/tests/unit/test_activity_yaml_validator.py @@ -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) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 60bed56..1c1c632 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -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 = '' - 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): diff --git a/tests/unit/test_random_buckets.py b/tests/unit/test_random_buckets.py index 0d35f99..d34a896 100644 --- a/tests/unit/test_random_buckets.py +++ b/tests/unit/test_random_buckets.py @@ -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