Implement OpenCompletion Activity YAML v2.0 features for immersive activities

Add comprehensive v2.0 features to enhance activity creation:

Features Implemented:
- Template variables: {{metadata.key}}, {{current_attempt}}, etc.
- Conditional content blocks: show_if conditions for dynamic content
- Advanced metadata conditions: gte, lt, contains, regex, exists operators
- Conditional navigation: if/elif/else branching based on metadata
- Progressive hints system: Auto-display hints based on attempt number
- Weighted random selection: Probabilistic outcomes with custom weights
- Dynamic question text: Questions with template variables
- Built-in attempt counters: Access to current_attempt, max_attempts, attempts_remaining

Files Modified:
- activity.py: Integrated all v2.0 features into activity execution
- activity_utils.py: New utility module for templates and conditions
- activity_yaml_validator.py: Updated validator for v2.0 schema
- CLAUDE.md: Added session persistence and Twitch Plays model docs
- research/SPEC.yaml: Comprehensive v2.0 feature documentation

Added:
- research/activity-test-v2-features.yaml: Test activity demonstrating all features

All changes validated and tested. Zero errors in validator.
This commit is contained in:
Claude 2025-11-10 15:13:02 +00:00
parent 7c61328943
commit 2b19fc5b9d
No known key found for this signature in database
6 changed files with 1337 additions and 78 deletions

View file

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

View file

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

352
activity_utils.py Normal file
View file

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

View file

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

View file

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

View file

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