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:
parent
7c61328943
commit
2b19fc5b9d
6 changed files with 1337 additions and 78 deletions
30
CLAUDE.md
30
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:
|
||||
|
|
|
|||
241
activity.py
241
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
|
||||
)
|
||||
|
|
|
|||
352
activity_utils.py
Normal file
352
activity_utils.py
Normal 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
|
||||
}
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ==============================================================================
|
||||
|
|
|
|||
203
research/activity-test-v2-features.yaml
Normal file
203
research/activity-test-v2-features.yaml
Normal 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!"
|
||||
Loading…
Add table
Add a link
Reference in a new issue