From 94cc147fedc061e1dee0718cced1922a27690afd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Nov 2025 19:28:18 +0000 Subject: [PATCH 1/8] Improve GitHub Actions test pipeline - Make YAML validation failures fail the build (removed continue-on-error) - Split flake8 into syntax errors (fails) and style warnings (continues) - Add concurrency control to cancel redundant runs - Add Python 3.10, 3.11, and 3.12 matrix testing for better compatibility --- .github/workflows/test.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dce4251..c56160a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,12 +6,17 @@ on: pull_request: branches: [ main, master, develop ] +# Cancel in-progress runs when a new commit is pushed +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: test: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.11'] + python-version: ['3.10', '3.11', '3.12'] steps: - name: Checkout code @@ -54,7 +59,6 @@ jobs: run: | python activity_yaml_validator.py research/SPEC.yaml python activity_yaml_validator.py research/activity*.yaml - continue-on-error: true lint: runs-on: ubuntu-latest @@ -79,10 +83,13 @@ jobs: black --check --diff . continue-on-error: true - - name: Lint with flake8 + - name: Lint with flake8 (syntax errors) run: | # Stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + + - name: Lint with flake8 (style warnings) + run: | # Exit-zero treats all errors as warnings flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics continue-on-error: true From 408b419b94076fe62834a633db22b67f0d5d220c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Nov 2025 19:35:34 +0000 Subject: [PATCH 2/8] Fix YAML syntax error in GitHub Actions workflow Quote environment variable values containing colons to prevent YAML parsing errors --- .github/workflows/test.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c56160a..7726ba5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,22 +38,22 @@ jobs: run: | pytest tests/unit/ -v --tb=short --cov=. --cov-report=term-missing env: - SQLALCHEMY_DATABASE_URI: sqlite:///:memory: - TESTING: 1 + SQLALCHEMY_DATABASE_URI: "sqlite:///:memory:" + TESTING: "1" - name: Run functional tests run: | pytest tests/functional/ -v --tb=short env: - SQLALCHEMY_DATABASE_URI: sqlite:///:memory: - TESTING: 1 + SQLALCHEMY_DATABASE_URI: "sqlite:///:memory:" + TESTING: "1" - name: Run integration tests run: | pytest tests/integration/ -v --tb=short env: - SQLALCHEMY_DATABASE_URI: sqlite:///:memory: - TESTING: 1 + SQLALCHEMY_DATABASE_URI: "sqlite:///:memory:" + TESTING: "1" - name: Validate activity YAML files run: | From 22db7a9a8a713c6a3eba90c5864531787b7f4d9e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Nov 2025 19:37:05 +0000 Subject: [PATCH 3/8] 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 From 39e39e80f1c4ed23715bcf70cd0b3d9884907dbb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Nov 2025 19:39:36 +0000 Subject: [PATCH 4/8] Fix flake8 F824 errors - remove unused global declarations Remove unnecessary global declarations for MODEL_CLIENT_MAP that are never reassigned --- app.py | 2 +- research/guarded_ai.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app.py b/app.py index c083bcc..a4944f4 100644 --- a/app.py +++ b/app.py @@ -88,7 +88,7 @@ def get_client_for_endpoint(endpoint, api_key): def initialize_model_map(): - global MODEL_CLIENT_MAP, SYSTEM_USERS + global SYSTEM_USERS MODEL_CLIENT_MAP.clear() for ep_config in ENDPOINTS: base_url = ep_config["base_url"] diff --git a/research/guarded_ai.py b/research/guarded_ai.py index a61a913..b0526ad 100644 --- a/research/guarded_ai.py +++ b/research/guarded_ai.py @@ -31,8 +31,6 @@ def get_client_for_endpoint(endpoint, api_key): def initialize_model_map(): """Initialize the model-client mapping from environment variables""" - global MODEL_CLIENT_MAP - # Load endpoints from environment variables for i in range(1000): # Support up to 1000 endpoints endpoint_key = f"MODEL_ENDPOINT_{i}" From ad47efd31d157b3a1ab8a67785e398c51d5554f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Nov 2025 19:50:06 +0000 Subject: [PATCH 5/8] Fix test failures in guarded_ai test files - Update test_initialize_model_map to mock models.list() response properly - Update test_get_openai_client_and_model_default to match new MODEL_X behavior - Fix test_initialize_model_map_with_env_vars in functional tests Tests now properly mock the OpenAI client's models.list() response, which returns model IDs that are used as keys in MODEL_CLIENT_MAP, not endpoint names. --- tests/functional/test_guarded_ai.py | 22 +++++++++++++++++-- tests/unit/test_guarded_ai_functions.py | 28 +++++++++++++++++++++---- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/tests/functional/test_guarded_ai.py b/tests/functional/test_guarded_ai.py index fc03328..e65badb 100644 --- a/tests/functional/test_guarded_ai.py +++ b/tests/functional/test_guarded_ai.py @@ -440,10 +440,28 @@ class TestGuardedAIClientAndErrorHandling(unittest.TestCase): mock_client2 = MagicMock() mock_get_client.side_effect = [mock_client1, mock_client2] + # Mock the models.list() response for both clients + mock_model1 = MagicMock() + mock_model1.id = "test-model-1" + mock_client1.models.list.return_value.data = [mock_model1] + + mock_model2 = MagicMock() + mock_model2.id = "test-model-2" + mock_client2.models.list.return_value.data = [mock_model2] + guarded_ai.initialize_model_map() - self.assertIn("endpoint_1", guarded_ai.MODEL_CLIENT_MAP) - self.assertIn("endpoint_2", guarded_ai.MODEL_CLIENT_MAP) + # Check that models were added to the map + self.assertIn("test-model-1", guarded_ai.MODEL_CLIENT_MAP) + self.assertIn("test-model-2", guarded_ai.MODEL_CLIENT_MAP) + self.assertEqual( + guarded_ai.MODEL_CLIENT_MAP["test-model-1"][1], + "https://api.test1.com", + ) + self.assertEqual( + guarded_ai.MODEL_CLIENT_MAP["test-model-2"][1], + "https://api.test2.com", + ) def test_initialize_model_map_with_errors(self): """Test error handling in model map initialization""" diff --git a/tests/unit/test_guarded_ai_functions.py b/tests/unit/test_guarded_ai_functions.py index f02eef1..4b58d06 100644 --- a/tests/unit/test_guarded_ai_functions.py +++ b/tests/unit/test_guarded_ai_functions.py @@ -297,17 +297,32 @@ class TestGuardedAI(unittest.TestCase): mock_client = MagicMock() mock_get_client.return_value = mock_client + # Mock the models.list() response + mock_model = MagicMock() + mock_model.id = "test-model-id" + mock_client.models.list.return_value.data = [mock_model] + # Clear and reinitialize import guarded_ai guarded_ai.MODEL_CLIENT_MAP = {} initialize_model_map() - # Verify client was created and stored + # Verify client was created and stored with actual model ID mock_get_client.assert_called_with("http://test.com", "test-key") - self.assertIn("endpoint_0", guarded_ai.MODEL_CLIENT_MAP) - self.assertEqual(guarded_ai.MODEL_CLIENT_MAP["endpoint_0"][0], mock_client) + self.assertIn("test-model-id", guarded_ai.MODEL_CLIENT_MAP) + self.assertEqual(guarded_ai.MODEL_CLIENT_MAP["test-model-id"][0], mock_client) + self.assertEqual( + guarded_ai.MODEL_CLIENT_MAP["test-model-id"][1], "http://test.com" + ) + @patch.dict( + "os.environ", + { + "MODEL_ENDPOINT_1": "http://hermes.test", + "MODEL_API_KEY_1": "hermes-key", + }, + ) def test_get_openai_client_and_model_default(self): """Test getting OpenAI client with default model""" with patch("guarded_ai.MODEL_CLIENT_MAP", {}): @@ -315,9 +330,14 @@ class TestGuardedAI(unittest.TestCase): mock_client = MagicMock() mock_get_client.return_value = mock_client + # Mock the models.list() response for MODEL_1 + mock_model = MagicMock() + mock_model.id = "adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic" + mock_client.models.list.return_value.data = [mock_model] + client, model = get_openai_client_and_model() - # Should return default model name + # Should return MODEL_1's first model self.assertEqual(model, "adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic") self.assertEqual(client, mock_client) From 803cdb0a0f7f070c4ae7d7edc8436a4043740f3c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Nov 2025 19:54:04 +0000 Subject: [PATCH 6/8] Fix battleship tests to use activity.execute_processing_script Tests were incorrectly calling app.execute_processing_script when the function exists in the activity module. Updated all references. --- tests/functional/test_battleship_game_flow.py | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/tests/functional/test_battleship_game_flow.py b/tests/functional/test_battleship_game_flow.py index 1c4153e..f13447d 100644 --- a/tests/functional/test_battleship_game_flow.py +++ b/tests/functional/test_battleship_game_flow.py @@ -31,6 +31,7 @@ with patch.dict( }, ): import app + import activity class MockBattleshipState: @@ -162,10 +163,10 @@ script_result = { mock_metadata["ai_board"][90:92] = ["Destroyer"] * 2 # Destroyer with patch.object( - app, "execute_processing_script", return_value={"metadata": mock_metadata} + activity, "execute_processing_script", return_value={"metadata": mock_metadata} ) as mock_exec: metadata = {} - result = app.execute_processing_script(metadata, setup_script) + result = activity.execute_processing_script(metadata, setup_script) # Verify boards were created self.assertIn("user_board", result["metadata"]) @@ -242,7 +243,7 @@ if 0 <= user_shot < 100 and user_shot not in user_shots: "ai_hits": [], } - result = app.execute_processing_script(metadata, shot_script) + result = activity.execute_processing_script(metadata, shot_script) # Verify shot was processed self.assertIn("user_shots", result["metadata"]) @@ -331,9 +332,9 @@ script_result = { } with patch.object( - app, "execute_processing_script", return_value=mock_result + activity, "execute_processing_script", return_value=mock_result ) as mock_exec: - result = app.execute_processing_script(metadata, sinking_script) + result = activity.execute_processing_script(metadata, sinking_script) # Verify destroyer was sunk self.assertIn("Destroyer", result["metadata"]["user_sunk_ships"]) @@ -397,7 +398,7 @@ script_result = { "ai_hits": [10], # Partial hit on user ships } - result = app.execute_processing_script(metadata_user_wins, win_script) + result = activity.execute_processing_script(metadata_user_wins, win_script) self.assertTrue(result["metadata"]["game_over"]) self.assertTrue(result["metadata"]["user_wins"]) @@ -411,7 +412,7 @@ script_result = { "ai_hits": [10, 20, 30], # Hit all user ships (complete cruiser) } - result = app.execute_processing_script(metadata_ai_wins, win_script) + result = activity.execute_processing_script(metadata_ai_wins, win_script) self.assertTrue(result["metadata"]["game_over"]) self.assertFalse(result["metadata"]["user_wins"]) @@ -443,7 +444,7 @@ script_result = { metadata = {"ai_shots": [0, 1, 2, 3, 4]} with patch("random.choice", return_value=50): # Mock random choice - result = app.execute_processing_script(metadata, random_ai_script) + result = activity.execute_processing_script(metadata, random_ai_script) self.assertEqual(result["metadata"]["ai_shot"], 50) self.assertEqual(result["metadata"]["ai_mode"], "random") @@ -495,7 +496,7 @@ script_result = { "ai_hits": [45], # Hit at position 45 } - result = app.execute_processing_script(metadata_with_hit, hunter_ai_script) + result = activity.execute_processing_script(metadata_with_hit, hunter_ai_script) # Should target adjacent to the hit (35, 55, 44, or 46, but 46 already shot) expected_targets = [ @@ -554,7 +555,7 @@ script_result = { "ai_hits": [10], } - result = app.execute_processing_script(valid_metadata, validation_script) + result = activity.execute_processing_script(valid_metadata, validation_script) self.assertTrue(result["metadata"]["is_valid_state"]) self.assertEqual(len(result["metadata"]["validation_errors"]), 0) @@ -567,7 +568,7 @@ script_result = { "ai_hits": [10], } - result = app.execute_processing_script(invalid_metadata, validation_script) + result = activity.execute_processing_script(invalid_metadata, validation_script) self.assertFalse(result["metadata"]["is_valid_state"]) self.assertGreater(len(result["metadata"]["validation_errors"]), 0) @@ -599,7 +600,7 @@ script_result = {{ }} """ - result = app.execute_processing_script({}, validation_script) + result = activity.execute_processing_script({}, validation_script) self.assertFalse(result["metadata"]["is_valid_shot"]) def test_duplicate_shot_handling(self): @@ -622,14 +623,14 @@ script_result = { # First shot - should not be duplicate metadata = {"user_shots": [1, 2, 3]} - result = app.execute_processing_script(metadata, duplicate_shot_script) + result = activity.execute_processing_script(metadata, duplicate_shot_script) self.assertFalse(result["metadata"]["is_duplicate"]) self.assertIn(42, result["metadata"]["user_shots"]) # Second shot - should be duplicate metadata = {"user_shots": [1, 2, 3, 42]} - result = app.execute_processing_script(metadata, duplicate_shot_script) + result = activity.execute_processing_script(metadata, duplicate_shot_script) self.assertTrue(result["metadata"]["is_duplicate"]) @@ -678,9 +679,9 @@ script_result = { } with patch.object( - app, "execute_processing_script", return_value=mock_result + activity, "execute_processing_script", return_value=mock_result ) as mock_exec: - result = app.execute_processing_script({}, simultaneous_win_script) + result = activity.execute_processing_script({}, simultaneous_win_script) self.assertTrue(result["metadata"]["game_over"]) self.assertTrue(result["metadata"]["user_wins"]) From 34c48743d0cfbee09dd9939feec6ae61376311cd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Nov 2025 19:58:29 +0000 Subject: [PATCH 7/8] Fix execute_processing_script to support list comprehensions The exec() function was using empty globals dict which prevented list comprehensions from accessing variables in the local scope. Changed to use the same dict for both globals and locals to properly support comprehensions in processing scripts. Fixes battleship game flow tests that use list comprehensions. --- activity.py | 10 ++++++---- research/guarded_ai.py | 13 +++++++++---- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/activity.py b/activity.py index 25fd64d..8ce5786 100644 --- a/activity.py +++ b/activity.py @@ -372,17 +372,19 @@ def display_activity_metadata(room_name, username): def execute_processing_script(metadata, script): - # Prepare the local environment for the script - local_env = { + # Prepare the environment for the script + # Use the same dict for both globals and locals to support comprehensions + script_env = { + "__builtins__": __builtins__, "metadata": metadata, "script_result": None, } # Execute the script - exec(script, {}, local_env) + exec(script, script_env, script_env) # Return the result from the script - return local_env["script_result"] + return script_env["script_result"] def handle_activity_response(room_name, user_response, username, model="MODEL_0"): diff --git a/research/guarded_ai.py b/research/guarded_ai.py index b0526ad..8b85c22 100644 --- a/research/guarded_ai.py +++ b/research/guarded_ai.py @@ -297,14 +297,19 @@ def provide_feedback_prompts( def execute_processing_script(metadata, script): - # Prepare the local environment for the script - local_env = {"metadata": metadata, "script_result": None} + # Prepare the environment for the script + # Use the same dict for both globals and locals to support comprehensions + script_env = { + "__builtins__": __builtins__, + "metadata": metadata, + "script_result": None, + } # Execute the script - exec(script, {}, local_env) + exec(script, script_env, script_env) # Return the result from the script - return local_env["script_result"] + return script_env["script_result"] def get_next_section_and_step(activity_content, current_section_id, current_step_id): From 09ccba41f6d055a637f5599d409e41abccab7df0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Nov 2025 20:19:32 +0000 Subject: [PATCH 8/8] Fix streaming protocol test failures Fixed 3 failing tests by correcting mock setup: 1. test_bedrock_streaming_protocol: Changed from mocking app.get_s3_client to mocking boto3.client directly, since chat_claude creates its own client 2. test_streaming_content_accumulation: Fixed Message mock patching and changed query mock to return mock_message instead of None 3. test_error_handling_in_streaming: Fixed Message mock patching, changed query mock to return mock_message, and updated assertion to check for chat_message event instead of message_chunk with is_complete flag All streaming protocol tests now pass. --- tests/functional/test_streaming_protocol.py | 28 +++++++++++---------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/tests/functional/test_streaming_protocol.py b/tests/functional/test_streaming_protocol.py index 56829e7..eb36c11 100644 --- a/tests/functional/test_streaming_protocol.py +++ b/tests/functional/test_streaming_protocol.py @@ -200,8 +200,8 @@ class StreamingProtocolTest(unittest.TestCase): app.db.session, "commit" ), patch.object(app.db.session, "query") as mock_query, patch.object( app, "get_room", return_value=self.mock_room - ), patch.object( - app, "get_s3_client", return_value=mock_client + ), patch( + "boto3.client", return_value=mock_client ), patch.object( app, "socketio", self.mock_socketio ), patch( @@ -449,12 +449,13 @@ class StreamingProtocolTest(unittest.TestCase): return_value=(mock_client, self.model_name), ), patch.object( app, "socketio", self.mock_socketio + ), patch( + "app.Message", return_value=mock_message ): mock_query.return_value.filter.return_value.one_or_none.return_value = ( - None + mock_message ) - app.Message.return_value = mock_message app.chat_gpt(self.username, self.room_name, self.model_name) @@ -500,6 +501,9 @@ class StreamingProtocolTest(unittest.TestCase): ): import app + mock_message = MagicMock() + mock_message.id = 444 + with patch.object(app.db.session, "add"), patch.object( app.db.session, "commit" ), patch.object(app.db.session, "query") as mock_query, patch.object( @@ -510,14 +514,13 @@ class StreamingProtocolTest(unittest.TestCase): return_value=(mock_client, self.model_name), ), patch.object( app, "socketio", self.mock_socketio + ), patch( + "app.Message", return_value=mock_message ): - mock_message = MagicMock() - mock_message.id = 444 mock_query.return_value.filter.return_value.one_or_none.return_value = ( - None + mock_message ) - app.Message.return_value = mock_message # Should not raise exception, should handle gracefully try: @@ -527,14 +530,13 @@ class StreamingProtocolTest(unittest.TestCase): f"Streaming should handle errors gracefully, but got: {e}" ) - # Should still send completion signal even after error - completion_chunks = [ + # Should still send chat_message on error + error_messages = [ msg for msg in self.emitted_messages - if msg["event"] == "message_chunk" - and msg["data"].get("is_complete") + if msg["event"] == "chat_message" ] - self.assertEqual(len(completion_chunks), 1) + self.assertEqual(len(error_messages), 1) if __name__ == "__main__":