diff --git a/CLAUDE.md b/CLAUDE.md index 3d86bef..e9545a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,6 +78,36 @@ ## Activity YAML Schema +### Session Persistence & Multi-User Model ("Twitch Plays Pokemon") + +**How OpenCompletion Activities Work:** + +- **Single Shared Game State**: One activity instance per room/channel +- **Multiple Players**: Zero or more users can participate from different devices +- **Collaborative Control**: Any user can provide input to advance the shared game +- **Persistent Metadata**: State is stored in the database per-room, survives browser refreshes +- **Like "Twitch Plays Pokemon"**: Everyone sees the same state, anyone can control + +**Key Implications:** +- `metadata` is **shared** across all users in the room - it's the game state, not player-specific +- When user "Alice" adds metadata, user "Bob" sees it too (same activity instance) +- Use metadata for: scores, progress, choices, inventory, flags - anything that's part of the game +- All users see the same content_blocks, questions, and transitions +- Multiple users can answer the same question - first valid answer advances the game +- Activities can be canceled, which deletes the room's activity state + +**Session Lifecycle:** +1. Activity starts → Initial state saved to database (room_id, section_id, step_id, metadata) +2. Users interact → Metadata updates, state progresses through sections/steps +3. Activity completes → State deleted from database +4. Activity canceled → State deleted from database + +**Use Cases:** +- Classroom activities where teacher projects screen, students call out answers +- Collaborative puzzles where multiple people work together +- Public challenges where community collectively progresses +- Educational games where everyone learns from same shared experience + ### Model Configuration (New Feature) Activities can specify separate models for classification and feedback generation: diff --git a/activity.py b/activity.py index 97b4858..583c58c 100644 --- a/activity.py +++ b/activity.py @@ -25,6 +25,18 @@ get_openai_client_and_model = None # Import SYSTEM_USERS from app.py SYSTEM_USERS = None +# Import activity utilities for v2.0 features +from activity_utils import ( + render_template, + evaluate_condition, + check_conditions, + filter_content_blocks, + resolve_conditional_navigation, + select_weighted_random, + get_progressive_hint, + create_template_context +) + def handle_get_activity_status(data): """Get the current activity status for a room.""" @@ -121,28 +133,57 @@ def loop_through_steps_until_question( # Emit the current step content blocks if "content_blocks" in step: - content = "\n\n".join(step["content_blocks"]) - translated_content = translate_text(content, user_language, feedback_model) - new_message = Message( - username="System", content=translated_content, room_id=room.id + # Create template context + context = create_template_context( + metadata=activity_state.dict_metadata, + current_attempt=activity_state.attempts, + max_attempts=activity_state.max_attempts, + current_section=current_section_id, + current_step=current_step_id, + username=username ) - db.session.add(new_message) - db.session.commit() - socketio.emit( - "chat_message", - { - "id": new_message.id, - "username": "System", - "content": translated_content, - }, - room=room_name, + # Filter and render content blocks (supports conditional blocks and templates) + filtered_blocks = filter_content_blocks( + step["content_blocks"], + activity_state.dict_metadata, + context ) - socketio.sleep(0.1) + + if filtered_blocks: + content = "\n\n".join(filtered_blocks) + translated_content = translate_text(content, user_language, feedback_model) + new_message = Message( + username="System", content=translated_content, room_id=room.id + ) + db.session.add(new_message) + db.session.commit() + + socketio.emit( + "chat_message", + { + "id": new_message.id, + "username": "System", + "content": translated_content, + }, + room=room_name, + ) + socketio.sleep(0.1) # Check if the current step has a question if "question" in step: - question_content = step["question"] + # Create template context + context = create_template_context( + metadata=activity_state.dict_metadata, + current_attempt=activity_state.attempts, + max_attempts=activity_state.max_attempts, + current_section=current_section_id, + current_step=current_step_id, + username=username + ) + + # Render template variables in question + question_content = render_template(step["question"], context) translated_question_content = translate_text( question_content, user_language, feedback_model ) @@ -486,11 +527,11 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0" ) socketio.sleep(0.05) - # Check metadata conditions for the current step + # Check metadata conditions for the current step (v2.0 advanced conditions) if "metadata_conditions" in transition: - conditions_met = all( - activity_state.dict_metadata.get(key) == value - for key, value in transition["metadata_conditions"].items() + conditions_met = check_conditions( + activity_state.dict_metadata, + transition["metadata_conditions"] ) if not conditions_met: # Skip this transition if conditions not met @@ -685,6 +726,21 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0" metadata_tmp_keys.append(random_key) activity_state.add_metadata(random_key, random_value) + # Handle metadata_weighted_random (v2.0) + if "metadata_weighted_random" in transition: + 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(): + 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") if post_script and ( @@ -763,30 +819,48 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0" user_language = activity_state.dict_metadata.get("language", "English") - # Emit the transition content blocks if they exist + # Emit the transition content blocks if they exist (v2.0 with templates & conditions) if "content_blocks" in transition: - transition_content = "\n\n".join(transition["content_blocks"]) - translated_transition_content = translate_text( - transition_content, user_language, feedback_model + # Create template context + context = create_template_context( + metadata=activity_state.dict_metadata, + current_attempt=activity_state.attempts, + max_attempts=activity_state.max_attempts, + current_section=activity_state.section_id, + current_step=activity_state.step_id, + username=username ) - new_message = Message( - username="System", - content=translated_transition_content, - room_id=room.id, - ) - db.session.add(new_message) - db.session.commit() - socketio.emit( - "chat_message", - { - "id": new_message.id, - "username": "System", - "content": translated_transition_content, - }, - room=room_name, + # Filter and render content blocks (supports conditional blocks and templates) + filtered_blocks = filter_content_blocks( + transition["content_blocks"], + activity_state.dict_metadata, + context ) - socketio.sleep(0.1) + + if filtered_blocks: + transition_content = "\n\n".join(filtered_blocks) + translated_transition_content = translate_text( + transition_content, user_language, feedback_model + ) + new_message = Message( + username="System", + content=translated_transition_content, + room_id=room.id, + ) + db.session.add(new_message) + db.session.commit() + + socketio.emit( + "chat_message", + { + "id": new_message.id, + "username": "System", + "content": translated_transition_content, + }, + room=room_name, + ) + socketio.sleep(0.1) # if "correct" or max_attempts reached. # Provide feedback based on the category @@ -885,6 +959,43 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0" # End of multi-bucket processing loop + # Check for progressive hints (v2.0) + if "hints" in step and activity_state.attempts > 0: + context = create_template_context( + metadata=activity_state.dict_metadata, + current_attempt=activity_state.attempts + 1, # Next attempt + max_attempts=activity_state.max_attempts, + current_section=activity_state.section_id, + current_step=activity_state.step_id, + username=username + ) + 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) + new_message = Message( + username="System (Hint)", + content=translated_hint, + room_id=room.id + ) + db.session.add(new_message) + db.session.commit() + + socketio.emit( + "chat_message", + { + "id": new_message.id, + "username": "System (Hint)", + "content": translated_hint, + }, + room=room_name, + ) + socketio.sleep(0.1) + + # If hint doesn't count as attempt, don't increment + if not hint['counts_as_attempt']: + any_counts_as_attempt = False + if ( category not in [ @@ -898,20 +1009,32 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0" or final_next_section_and_step # Use final navigation from last transition ): if final_next_section_and_step: - ( - current_section_id, - current_step_id, - ) = final_next_section_and_step.split(":") - next_section = next( - s - for s in activity_content["sections"] - if s["section_id"] == current_section_id - ) - next_step = next( - s - for s in next_section["steps"] - if s["step_id"] == current_step_id + # Resolve conditional navigation (v2.0) + resolved_navigation = resolve_conditional_navigation( + final_next_section_and_step, + activity_state.dict_metadata ) + + if resolved_navigation: + ( + current_section_id, + current_step_id, + ) = resolved_navigation.split(":") + next_section = next( + s + for s in activity_content["sections"] + if s["section_id"] == current_section_id + ) + next_step = next( + s + for s in next_section["steps"] + if s["step_id"] == current_step_id + ) + else: + # No navigation resolved, move to next step + next_section, next_step = get_next_step( + activity_content, section["section_id"], step["step_id"] + ) else: # Move to the next step or section next_section, next_step = get_next_step( @@ -939,8 +1062,16 @@ def handle_activity_response(room_name, user_response, username, model="MODEL_0" db.session.add(activity_state) db.session.commit() - # Emit the question again - question_content = step["question"] + # Emit the question again (v2.0 with templates) + context = create_template_context( + metadata=activity_state.dict_metadata, + current_attempt=activity_state.attempts, + max_attempts=activity_state.max_attempts, + current_section=activity_state.section_id, + current_step=activity_state.step_id, + username=username + ) + question_content = render_template(step["question"], context) translated_question_content = translate_text( question_content, user_language, feedback_model ) diff --git a/activity_utils.py b/activity_utils.py new file mode 100644 index 0000000..7ebdd35 --- /dev/null +++ b/activity_utils.py @@ -0,0 +1,352 @@ +""" +Utility functions for OpenCompletion Activity System v2.0 + +Features: +- Template variable rendering ({{metadata.key}}, {{current_attempt}}, etc.) +- Advanced metadata conditions (gte, lt, contains, regex, etc.) +- Conditional content blocks (show_if) +- Conditional navigation (if/elif/else) +- Weighted random selection +- Progressive hints +""" + +import re +import random +from typing import Any, Dict, List, Optional, Union + + +def render_template(text: str, context: Dict[str, Any]) -> str: + """ + Render template variables in text using {{variable}} syntax. + + Supports: + - {{metadata.key}} - Access metadata values + - {{current_attempt}} - Current attempt number + - {{max_attempts}} - Maximum attempts + - {{attempts_remaining}} - Remaining attempts + - {{current_section}} - Current section ID + - {{current_step}} - Current step ID + - {{username}} - Last responding username + + Args: + text: Text containing {{variable}} templates + context: Dictionary with metadata, attempts, section/step info + + Returns: + Text with variables replaced + """ + if not isinstance(text, str): + return text + + # Find all {{variable}} patterns + pattern = r'\{\{([^}]+)\}\}' + + def replace_variable(match): + var_name = match.group(1).strip() + + # Handle metadata.key syntax + 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 '' + + # 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 '' + + return re.sub(pattern, replace_variable, text) + + +def evaluate_condition(metadata: Dict[str, Any], condition_key: str, condition_value: Any) -> bool: + """ + Evaluate a single condition against metadata. + + Supports operators: + - key: value - Equality + - key_ne: value - Not equal + - key_gt: value - Greater than + - key_gte: value - Greater than or equal + - key_lt: value - Less than + - key_lte: value - Less than or equal + - key_between: [min, max] - Between (inclusive) + - key_contains: value - Comma-separated list contains value + - key_not_contains: value - List does NOT contain value + - key_matches: pattern - Regex match + - key_exists: true/false - Key existence check + - key_not_exists: true/false - Key non-existence check + + Args: + metadata: Metadata dictionary to check + condition_key: Condition key (may have operator suffix) + condition_value: Expected value + + Returns: + True if condition met, False otherwise + """ + # Check for operator suffixes + if condition_key.endswith('_ne'): + key = condition_key[:-3] + return metadata.get(key) != condition_value + + 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'): + key = condition_key[:-4] + try: + return float(metadata.get(key, 0)) >= float(condition_value) + except (ValueError, TypeError): + return False + + 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'): + key = condition_key[:-4] + try: + return float(metadata.get(key, 0)) <= float(condition_value) + except (ValueError, TypeError): + return False + + elif condition_key.endswith('_between'): + key = condition_key[:-8] + if not isinstance(condition_value, list) or len(condition_value) != 2: + return False + try: + val = float(metadata.get(key, 0)) + return float(condition_value[0]) <= val <= float(condition_value[1]) + except (ValueError, TypeError): + return False + + elif condition_key.endswith('_contains'): + key = condition_key[:-9] + 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()] + return str(condition_value) in items + + 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()] + return str(condition_value) not in items + + elif condition_key.endswith('_matches'): + key = condition_key[:-8] + 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('_exists'): + key = condition_key[:-7] + if condition_value: + return key in metadata + else: + return key not in metadata + + elif condition_key.endswith('_not_exists'): + key = condition_key[:-11] + if condition_value: + return key not in metadata + else: + return key in metadata + + else: + # Simple equality check + return metadata.get(condition_key) == condition_value + + +def check_conditions(metadata: Dict[str, Any], conditions: Dict[str, Any]) -> bool: + """ + Check if ALL conditions are met (AND logic). + + Args: + metadata: Metadata dictionary + conditions: Dictionary of condition_key: condition_value pairs + + Returns: + True if all conditions met, False otherwise + """ + if not conditions: + return True + + return all( + 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] +) -> List[str]: + """ + Filter and render content blocks based on show_if conditions. + + Content blocks can be: + - Simple strings: Always shown + - Objects with 'text' and 'show_if': Conditionally shown + + Args: + content_blocks: List of content blocks (strings or dicts) + metadata: Metadata dictionary for condition evaluation + context: Template rendering context + + Returns: + List of rendered text strings that passed conditions + """ + result = [] + + for block in content_blocks: + if isinstance(block, str): + # Simple string - always show, just render templates + rendered = render_template(block, context) + result.append(rendered) + + elif isinstance(block, dict): + # Conditional block - check show_if condition + text = block.get('text', '') + show_if = block.get('show_if', {}) + + # Check if conditions are met + if check_conditions(metadata, show_if): + rendered = render_template(text, context) + result.append(rendered) + + return result + + +def resolve_conditional_navigation( + next_section_and_step: Union[str, List[Dict[str, Any]]], + metadata: Dict[str, Any] +) -> Optional[str]: + """ + Resolve conditional navigation (if/elif/else structure). + + Args: + next_section_and_step: Either a string or list of conditional branches + metadata: Metadata dictionary for condition evaluation + + Returns: + Resolved "section:step" string or None + """ + # Simple string - return as-is + if isinstance(next_section_and_step, str): + return next_section_and_step + + # Conditional branches + if isinstance(next_section_and_step, list): + for branch in next_section_and_step: + if 'if' in branch: + # if branch + if check_conditions(metadata, branch['if']): + return branch.get('goto') + + elif 'elif' in branch: + # elif branch + if check_conditions(metadata, branch['elif']): + return branch.get('goto') + + elif 'else' in branch: + # else branch - always taken if reached + return branch.get('goto') + + return None + + +def select_weighted_random(weighted_options: List[Dict[str, Any]]) -> Any: + """ + Select a random value from weighted options. + + Args: + weighted_options: List of dicts with 'value' and 'weight' keys + + Returns: + Selected value + """ + if not weighted_options: + return None + + # Extract values and weights + 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) + return selected[0] + + +def get_progressive_hint( + 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. + + Args: + hints: List of hint dicts with 'attempt', 'text', 'counts_as_attempt' keys + current_attempt: Current attempt number (1, 2, 3, ...) + context: Template rendering context + + Returns: + Hint dict with rendered text, or None if no hint for this attempt + """ + if not hints: + return None + + for hint in hints: + if hint.get('attempt') == current_attempt: + # Render template variables in hint text + hint_text = render_template(hint.get('text', ''), context) + return { + 'text': hint_text, + 'counts_as_attempt': hint.get('counts_as_attempt', False) + } + + return None + + +def create_template_context( + metadata: Dict[str, Any], + current_attempt: int, + max_attempts: int, + current_section: str, + current_step: str, + username: str = "User" +) -> Dict[str, Any]: + """ + Create a template rendering context with all built-in variables. + + Args: + metadata: Activity metadata + current_attempt: Current attempt number + max_attempts: Maximum attempts allowed + current_section: Current section ID + current_step: Current step ID + username: Username of last responder + + Returns: + 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 + } diff --git a/activity_yaml_validator.py b/activity_yaml_validator.py index 48fab60..ce1aa23 100644 --- a/activity_yaml_validator.py +++ b/activity_yaml_validator.py @@ -81,7 +81,9 @@ class ActivityYAMLValidator: return len(self.errors) == 0, self.errors, self.warnings 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 def _validate_structure(self, data: Dict[str, Any]): @@ -228,7 +230,7 @@ class ActivityYAMLValidator: def _validate_content_blocks( self, content_blocks: List[str], section_id: str, step_id: str ): - """Validate content blocks""" + """Validate content blocks (v2.0 supports conditional blocks)""" if not isinstance(content_blocks, list): self.errors.append( f"Section {section_id}, step {step_id}: content_blocks must be a list" @@ -236,9 +238,28 @@ class ActivityYAMLValidator: return for i, block in enumerate(content_blocks): - if not isinstance(block, str): + if isinstance(block, str): + # Simple string block - always valid + continue + elif isinstance(block, dict): + # Conditional block (v2.0) + 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): + self.errors.append( + f"Section {section_id}, step {step_id}: content_blocks[{i}]['text'] must be a string" + ) + + 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" + ) + else: self.errors.append( - f"Section {section_id}, step {step_id}: content_blocks[{i}] must be a string" + f"Section {section_id}, step {step_id}: content_blocks[{i}] must be a string or dict" ) def _validate_question_step( @@ -269,6 +290,10 @@ class ActivityYAMLValidator: step["feedback_prompts"], section_id, step_id ) + # Validate hints (v2.0 progressive hints) + if "hints" in step: + self._validate_hints(step["hints"], section_id, step_id) + # Validate buckets and transitions if "buckets" in step: self._validate_buckets(step["buckets"], section_id, step_id) @@ -469,16 +494,21 @@ class ActivityYAMLValidator: ) return - # Validate next_section_and_step format + # Validate next_section_and_step format (v2.0 supports conditional navigation) if "next_section_and_step" in transition: next_step = transition["next_section_and_step"] - if not isinstance(next_step, str): + if isinstance(next_step, str): + # Simple string navigation + if ":" not in next_step: + self.errors.append( + f"Section {section_id}, step {step_id}, bucket {bucket}: 'next_section_and_step' must be in format 'section_id:step_id'" + ) + elif isinstance(next_step, list): + # Conditional navigation (v2.0) + 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" - ) - elif ":" not in next_step: - self.errors.append( - f"Section {section_id}, step {step_id}, bucket {bucket}: 'next_section_and_step' must be in format 'section_id:step_id'" + f"Section {section_id}, step {step_id}, bucket {bucket}: 'next_section_and_step' must be a string or list" ) # Validate metadata operations @@ -488,6 +518,8 @@ class ActivityYAMLValidator: "metadata_remove", "metadata_clear", "metadata_feedback_filter", + "metadata_weighted_random", # v2.0 + "metadata_tmp_weighted_random", # v2.0 ] for field in metadata_fields: if field in transition: @@ -554,11 +586,110 @@ class ActivityYAMLValidator: f"Section {section_id}, step {step_id}, bucket {bucket}: 'content_blocks' must be a list" ) else: - for i, block in enumerate(transition["content_blocks"]): - if not isinstance(block, str): - self.errors.append( - f"Section {section_id}, step {step_id}, bucket {bucket}: content_blocks[{i}] must be a string" - ) + # 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}") + + 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( + f"Section {section_id}, step {step_id}: 'hints' must be a list" + ) + return + + if not hints: + self.warnings.append( + f"Section {section_id}, step {step_id}: Empty hints list" + ) + return + + for i, hint in enumerate(hints): + if not isinstance(hint, dict): + self.errors.append( + f"Section {section_id}, step {step_id}: hints[{i}] must be a dictionary" + ) + continue + + # Validate required fields + 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: + self.errors.append( + f"Section {section_id}, step {step_id}: hints[{i}]['attempt'] must be a positive integer" + ) + + 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): + self.errors.append( + f"Section {section_id}, step {step_id}: hints[{i}]['text'] must be a string" + ) + + # Validate optional fields + 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" + ) + + def _validate_conditional_navigation( + self, nav_list: List[Dict[str, Any]], bucket: str, section_id: str, step_id: str + ): + """Validate conditional navigation structure (v2.0)""" + if not isinstance(nav_list, list): + self.errors.append( + f"Section {section_id}, step {step_id}, bucket {bucket}: conditional navigation must be a list" + ) + return + + has_else = False + for i, branch in enumerate(nav_list): + if not isinstance(branch, dict): + self.errors.append( + f"Section {section_id}, step {step_id}, bucket {bucket}: navigation[{i}] must be a dictionary" + ) + continue + + # Check for if/elif/else + 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): + self.errors.append( + f"Section {section_id}, step {step_id}, bucket {bucket}: navigation[{i}]['elif'] must be a dict" + ) + elif 'else' in branch: + has_else = True + # else doesn't need conditions + else: + self.errors.append( + f"Section {section_id}, step {step_id}, bucket {bucket}: navigation[{i}] must have 'if', 'elif', or 'else'" + ) + + # Check for goto + 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): + self.errors.append( + f"Section {section_id}, step {step_id}, bucket {bucket}: navigation[{i}]['goto'] must be a string" + ) + 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'" + ) + + if not has_else: + self.warnings.append( + f"Section {section_id}, step {step_id}, bucket {bucket}: conditional navigation has no 'else' clause - may not always resolve" + ) def _validate_python_code(self, data: Dict[str, Any]): """Validate Python code blocks in scripts""" @@ -697,9 +828,12 @@ class ActivityYAMLValidator: # Check if any transition continues the flow has_continuing_transition = False for transition in step["transitions"].values(): - if "next_section_and_step" in transition: - has_continuing_transition = True - break + 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 + has_continuing_transition = True + break # If this is the last step of the last section and has no continuing transitions if ( @@ -808,12 +942,24 @@ class ActivityYAMLValidator: continue for bucket, transition in step["transitions"].items(): - if "next_section_and_step" in transition: + if isinstance(transition, dict) and "next_section_and_step" in transition: target = transition["next_section_and_step"] - if target not in all_steps: - self.errors.append( - f"Section {section_id}, step {step_id}: Invalid transition target '{target}'" - ) + + # v2.0: target can be string or list (conditional navigation) + if isinstance(target, str): + if target not in all_steps: + self.errors.append( + f"Section {section_id}, step {step_id}: Invalid transition target '{target}'" + ) + 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 goto_target not in all_steps: + self.errors.append( + f"Section {section_id}, step {step_id}: Invalid conditional navigation target '{goto_target}'" + ) def main(): diff --git a/research/SPEC.yaml b/research/SPEC.yaml index 5023d16..3576780 100644 --- a/research/SPEC.yaml +++ b/research/SPEC.yaml @@ -632,6 +632,403 @@ sections: # 42 → Store integer # true / false → Store boolean +# ============================================================================== +# ADVANCED FEATURES (New in v2.0) +# ============================================================================== + +# ============================================================================== +# TEMPLATE VARIABLES +# ============================================================================== +# Use {{variable_name}} syntax to insert dynamic values into content + +# Available in: content_blocks, questions, ai_feedback tokens + +# Built-in Variables: +# ------------------- +# {{current_attempt}} → Current attempt number (1, 2, 3...) +# {{max_attempts}} → Maximum attempts allowed for this step +# {{attempts_remaining}} → How many attempts left (max - current) +# {{current_section}} → Current section_id +# {{current_step}} → Current step_id +# {{username}} → Name of the user who last responded + +# Metadata Variables: +# ------------------- +# {{metadata.key_name}} → Access any metadata value +# {{metadata.score}} → Example: access score +# {{metadata.player_name}} → Example: access player name + +# Example Usage: +content_blocks: + - "## Your Progress" + - "Welcome back, {{metadata.player_name}}!" + - "Score: {{metadata.score}}" + - "Level: {{metadata.level}}" + - "Attempt {{current_attempt}} of {{max_attempts}}" + - "You have {{attempts_remaining}} tries remaining" + +question: "{{metadata.character_name}} asks: What will you do?" + +# Templates work in: +# - step content_blocks +# - transition content_blocks +# - question text +# - ai_feedback tokens_for_ai (for context, not rendered directly) + +# ============================================================================== +# CONDITIONAL CONTENT BLOCKS +# ============================================================================== +# Show/hide content blocks based on metadata conditions + +# Format: Each content block can be a string OR an object with conditions + +content_blocks: + # Simple string - always shown + - "This is always displayed" + + # Conditional block - only shown if conditions met + - text: "You're doing great! Keep going!" + show_if: + score_gte: 50 # Only show if score >= 50 + + - text: "Need more practice. Don't give up!" + show_if: + score_lt: 50 # Only show if score < 50 + + - text: "You found the secret key! 🗝️" + show_if: + inventory_contains: "key" # Only if inventory contains "key" + + - text: "Welcome, warrior! ⚔️" + show_if: + class: "warrior" # Only if metadata.class equals "warrior" + + - text: "Welcome, mage! 🔮" + show_if: + class: "mage" + +# Conditional blocks reduce step duplication - one step, multiple paths! + +# ============================================================================== +# ADVANCED METADATA CONDITIONS +# ============================================================================== +# Rich comparison operators for metadata_conditions + +# Previously only supported equality: +metadata_conditions: + level: 5 # metadata.level must equal 5 + +# Now supports: +# -------------- + +# Equality & Inequality: +metadata_conditions: + status: "active" # Equal to "active" + status_ne: "inactive" # Not equal to "inactive" + +# Numeric Comparisons: +metadata_conditions: + score_gte: 100 # Greater than or equal to 100 + score_gt: 99 # Greater than 99 + score_lt: 200 # Less than 200 + score_lte: 199 # Less than or equal to 199 + level_between: [5, 10] # Between 5 and 10 (inclusive) + +# String Operations: +metadata_conditions: + inventory_contains: "sword" # Comma-separated list contains "sword" + inventory_not_contains: "poison" # List does NOT contain "poison" + name_matches: "^[A-Z]" # Regex match (starts with capital) + +# Existence Checks: +metadata_conditions: + has_key_exists: true # Key "has_key" must exist in metadata + temp_flag_not_exists: true # Key "temp_flag" must NOT exist + +# Boolean Checks: +metadata_conditions: + is_admin: true # metadata.is_admin must be true + is_locked: false # metadata.is_locked must be false + +# Combining Multiple Conditions (ALL must be true): +metadata_conditions: + score_gte: 100 + level_gte: 5 + inventory_contains: "key" + quest_completed: true +# All four conditions must be met + +# ============================================================================== +# CONDITIONAL NAVIGATION +# ============================================================================== +# Choose different paths based on metadata state + +# OLD WAY (still works): +transitions: + answer_provided: + next_section_and_step: "section_2:step_1" + +# NEW WAY - Conditional branches: +transitions: + answer_provided: + next_section_and_step: + - if: + score_gte: 100 + goto: "expert:challenge" + + - elif: + score_gte: 50 + goto: "intermediate:lesson" + + - elif: + score_gte: 25 + goto: "beginner:practice" + + - else: + goto: "tutorial:basics" + +# Another example: Quest completion paths +transitions: + quest_complete: + next_section_and_step: + - if: + all_secrets_found: true + perfect_score: true + goto: "endings:perfect_ending" + + - elif: + all_secrets_found: true + goto: "endings:good_ending" + + - elif: + quest_failed: true + goto: "endings:bad_ending" + + - else: + goto: "endings:neutral_ending" + +# Conditions use same operators as metadata_conditions: +# - Equality: key: value +# - Comparisons: key_gte, key_gt, key_lt, key_lte +# - String ops: key_contains, key_not_contains, key_matches +# - Existence: key_exists, key_not_exists +# - Boolean: key: true/false + +# ============================================================================== +# PROGRESSIVE HINTS SYSTEM +# ============================================================================== +# Built-in system for providing hints that escalate with attempts + +# Define hints at step level: +- step_id: "difficult_question" + question: "What is the capital of Burkina Faso?" + + # Progressive hints based on attempt number + hints: + - attempt: 1 + text: "💡 Hint: It's not the largest city in the country." + counts_as_attempt: false # Showing hint doesn't count as failure + + - attempt: 2 + text: "💡 Hint: The name means 'City of Honest People'." + counts_as_attempt: false + + - attempt: 3 + text: "💡 Strong Hint: It starts with 'Oua' and ends with 'dougou'." + counts_as_attempt: false + + buckets: [correct, incorrect, need_hint] + + transitions: + correct: + content_blocks: + - "Excellent! Ouagadougou is correct!" + next_section_and_step: "next:step" + + incorrect: + content_blocks: + - "Not quite. Try again!" + # Hint will auto-display based on current_attempt + next_section_and_step: "current:difficult_question" + + need_hint: + content_blocks: + - "Let me help you..." + counts_as_attempt: false # Requesting hint doesn't count + next_section_and_step: "current:difficult_question" + +# Hints auto-display when attempt number matches +# Hints support template variables: "Attempt {{current_attempt}} of {{max_attempts}}" + +# ============================================================================== +# WEIGHTED RANDOM SELECTION +# ============================================================================== +# Choose random values with different probabilities + +# OLD WAY - Equal probability: +metadata_random: + loot: "sword" # 33% each + loot: "dagger" # 33% each + loot: "staff" # 33% each + +# NEW WAY - Weighted probabilities: +metadata_weighted_random: + loot: + - value: "common_sword" + weight: 70 # 70% chance + - value: "rare_dagger" + weight: 25 # 25% chance + - value: "legendary_staff" + weight: 5 # 5% chance + +# Weights don't need to sum to 100 - they're relative: +metadata_weighted_random: + reward: + - value: "gold" + weight: 10 # 10/(10+3+1) = 71.4% + - value: "gem" + weight: 3 # 3/(10+3+1) = 21.4% + - value: "artifact" + weight: 1 # 1/(10+3+1) = 7.1% + +# Also works with metadata_tmp_weighted_random for temporary values + +# Example: Random encounter +transitions: + explore_forest: + metadata_weighted_random: + encounter: + - value: "nothing" + weight: 50 # 50% - No encounter + - value: "merchant" + weight: 30 # 30% - Friendly merchant + - value: "goblin" + weight: 15 # 15% - Fight goblin + - value: "treasure" + weight: 5 # 5% - Find treasure! + + ai_feedback: + tokens_for_ai: | + Describe what happens based on metadata.encounter: + - nothing: Peaceful walk through forest + - merchant: Meet a traveling merchant + - goblin: Surprise goblin attack! + - treasure: Discover hidden treasure chest! + +# ============================================================================== +# DYNAMIC QUESTION TEXT +# ============================================================================== +# Questions can now use template variables + +# Static question (old way): +question: "What is 2 + 2?" + +# Dynamic question with templates (new way): +question: "What is {{metadata.num1}} + {{metadata.num2}}?" + +# Example: Math quiz with random numbers +- step_id: "addition" + pre_script: | + import random + result = { + "metadata": { + "num1": random.randint(1, 10), + "num2": random.randint(1, 10) + } + } + return result + + question: "What is {{metadata.num1}} + {{metadata.num2}}?" + + tokens_for_ai: | + Calculate the correct answer: {{metadata.num1}} + {{metadata.num2}} + Categorize as 'correct' if their answer matches. + + buckets: [correct, incorrect] + +# Example: Personalized questions +question: "{{metadata.character_name}}, what is your quest?" +question: "You have {{metadata.gold}} gold. How much do you spend?" +question: "Round {{current_attempt}}: What's your move?" + +# ============================================================================== +# BUILT-IN ATTEMPT COUNTER ACCESS +# ============================================================================== +# Access attempt information in templates + +# Available variables: +# - {{current_attempt}} : 1, 2, 3, ... (current attempt number) +# - {{max_attempts}} : 3 (or custom value from default_max_attempts_per_step) +# - {{attempts_remaining}} : max_attempts - current_attempt + +# Examples: + +content_blocks: + - "Attempt {{current_attempt}} of {{max_attempts}}" + - "You have {{attempts_remaining}} tries left" + +question: "Try {{current_attempt}}: What's your answer?" + +ai_feedback: + tokens_for_ai: | + This is attempt {{current_attempt}} of {{max_attempts}}. + {% if attempts_remaining == 1 %} + This is their last chance! Be clear and helpful. + {% elif attempts_remaining == 2 %} + They still have time. Provide a gentle hint. + {% else %} + Encourage them to think carefully. + {% endif %} + +# Conditional content based on attempts: +content_blocks: + - text: "First try - think carefully!" + show_if: + current_attempt: 1 + + - text: "Second try - you're getting closer!" + show_if: + current_attempt: 2 + + - text: "Last chance! Here's a hint..." + show_if: + current_attempt: 3 + +# ============================================================================== +# SESSION PERSISTENCE (Twitch Plays Model) +# ============================================================================== +# How metadata and state persist across users and sessions + +# Key Facts: +# ---------- +# 1. ONE GAME STATE PER ROOM: All users in a room share the same activity state +# 2. METADATA IS SHARED: When one user updates metadata, all users see it +# 3. DATABASE PERSISTENCE: State survives browser refreshes and reconnections +# 4. ANYONE CAN CONTROL: Any user can provide input to advance the shared game +# 5. LIKE TWITCH PLAYS POKEMON: Collaborative control of single game instance + +# Lifecycle: +# ---------- +# Activity starts → State saved to database (room_id, section_id, step_id, metadata) +# User interacts → Metadata updates, state progresses +# Browser refreshes → State persists (loaded from database) +# Activity completes → State deleted from database +# Activity canceled → State deleted from database + +# Use Cases: +# ---------- +# - Classroom: Teacher projects, students call out answers collectively +# - Collaboration: Multiple people solve puzzle together +# - Public challenges: Community progresses through shared experience +# - Learning together: Everyone learns from same shared game state + +# Implications for Activity Design: +# ---------------------------------- +# - Design for SHARED state, not per-player state +# - Metadata represents THE GAME, not individual players +# - Multiple users may answer - first valid response advances +# - Consider: "What if 10 people are playing together?" + # ============================================================================== # VALIDATION RULES # ============================================================================== diff --git a/research/activity-test-v2-features.yaml b/research/activity-test-v2-features.yaml new file mode 100644 index 0000000..b643c42 --- /dev/null +++ b/research/activity-test-v2-features.yaml @@ -0,0 +1,203 @@ +default_max_attempts_per_step: 3 +classifier_model: "MODEL_0" +feedback_model: "MODEL_0" + +tokens_for_ai_rubric: | + Test activity for v2.0 features. + Evaluate responses generously - this is just a demo! + +sections: + - section_id: intro + title: V2.0 Features Demo + steps: + # Test: Template variables in content blocks + - step_id: welcome + title: Welcome with Templates + content_blocks: + - "# Welcome to OpenCompletion V2.0! 🎉" + - "" + - "This activity demonstrates all new v2.0 features." + - "Current section: {{current_section}}" + - "Current step: {{current_step}}" + question: "What's your name?" + tokens_for_ai: | + Categorize as 'name_provided' if they give a name. + Otherwise 'off_topic'. + buckets: [name_provided, off_topic] + transitions: + name_provided: + content_blocks: + - "Great to meet you!" + metadata_add: + player_name: "the-users-response" + score: "n+1" + next_section_and_step: "templates:test_templates" + off_topic: + content_blocks: + - "Please tell me your name." + next_section_and_step: "intro:welcome" + + # Section: Template Variables + - section_id: templates + title: Template Variables Test + steps: + - step_id: test_templates + title: Testing Templates + content_blocks: + - "# Template Variables Test" + - "" + - "Welcome back, {{metadata.player_name}}!" + - "Your score: {{metadata.score}}" + - "Attempt {{current_attempt}} of {{max_attempts}}" + - "Attempts remaining: {{attempts_remaining}}" + question: "Ready to test conditional content? (yes/no)" + tokens_for_ai: "Categorize as 'yes' or 'no' based on their response." + buckets: [yes, no] + transitions: + yes: + content_blocks: + - "Excellent!" + next_section_and_step: "conditionals:test_conditional_blocks" + no: + content_blocks: + - "Take your time!" + next_section_and_step: "templates:test_templates" + + # Section: Conditional Content Blocks + - section_id: conditionals + title: Conditional Content Test + steps: + - step_id: test_conditional_blocks + title: Conditional Content Blocks + content_blocks: + # Always shown + - "# Conditional Content Test" + - "" + # Conditional - only if score >= 1 + - text: "🌟 You have points! Great job!" + show_if: + score_gte: 1 + # Conditional - only if score < 1 + - text: "Start earning points!" + show_if: + score_lt: 1 + # Conditional - personalized + - text: "Hello {{metadata.player_name}}, let's continue!" + show_if: + player_name_exists: true + question: "What's 5 + 3?" + tokens_for_ai: "Categorize as 'correct' if 8 or eight, otherwise 'incorrect'." + buckets: [correct, incorrect] + + # Progressive hints test + hints: + - attempt: 1 + text: "💡 Hint: It's less than 10" + counts_as_attempt: false + - attempt: 2 + text: "💡 Strong Hint: 5 + 3 = ?" + counts_as_attempt: false + + transitions: + correct: + content_blocks: + - "Perfect! ✅" + metadata_add: + score: "n+5" + next_section_and_step: "weighted_random:test_weighted" + incorrect: + content_blocks: + - "Try again!" + next_section_and_step: "conditionals:test_conditional_blocks" + + # Section: Weighted Random + - section_id: weighted_random + title: Weighted Random Test + steps: + - step_id: test_weighted + title: Weighted Random Selection + content_blocks: + - "# Weighted Random Test" + - "" + - "Let's test weighted random selection!" + question: "Roll the dice! (type 'roll')" + tokens_for_ai: "Categorize as 'roll'." + buckets: [roll] + transitions: + roll: + metadata_weighted_random: + loot: + - value: "common_item" + weight: 70 + - value: "rare_item" + weight: 25 + - value: "legendary_item" + weight: 5 + ai_feedback: + tokens_for_ai: | + The user found: {{metadata.loot}} + If common_item: "You found a Common Item" + If rare_item: "You found a Rare Item! 🌟" + If legendary_item: "LEGENDARY ITEM FOUND! 🏆" + metadata_add: + score: "n+1" + next_section_and_step: "conditional_nav:test_nav" + + # Section: Conditional Navigation + - section_id: conditional_nav + title: Conditional Navigation Test + steps: + - step_id: test_nav + title: Conditional Navigation + content_blocks: + - "# Conditional Navigation Test" + - "" + - "Your current score: {{metadata.score}}" + - "" + - "Based on your score, you'll be routed to different paths!" + question: "Continue? (yes)" + tokens_for_ai: "Categorize as 'continue'." + buckets: [continue] + transitions: + continue: + # Conditional navigation based on score + next_section_and_step: + - if: + score_gte: 10 + goto: "endings:high_score" + - elif: + score_gte: 5 + goto: "endings:medium_score" + - else: + goto: "endings:low_score" + + # Section: Different Endings + - section_id: endings + title: Endings + steps: + - step_id: high_score + title: High Score Ending + content_blocks: + - "# 🏆 AMAZING! High Score!" + - "" + - "{{metadata.player_name}}, you scored {{metadata.score}} points!" + - "" + - "You're a V2.0 features master!" + + - step_id: medium_score + title: Medium Score Ending + content_blocks: + - "# 🌟 GOOD JOB! Medium Score!" + - "" + - "{{metadata.player_name}}, you scored {{metadata.score}} points!" + - "" + - "Great understanding of V2.0 features!" + + - step_id: low_score + title: Low Score Ending + content_blocks: + - "# ✨ Good Start!" + - "" + - "{{metadata.player_name}}, you scored {{metadata.score}} points!" + - "" + - "You've learned the basics of V2.0 features!"